calcpace 1.9.1 → 1.9.2

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: ed70195cc42127f49e9dac13a4ff5bc99b3a7b59fc9564261f4b03d52434d002
4
- data.tar.gz: aa679a9e3876e166be6a3b5f74ccdb0293dcb017366e727331b9ca8579684917
3
+ metadata.gz: 03d52df60dcc776004ee27c76c98010904d0bc6ecfc839e81e4d8aa19e299b8d
4
+ data.tar.gz: d25c779a355310c08574b8b5c16ed9d946515fbe55c70c766569f13a57774d5c
5
5
  SHA512:
6
- metadata.gz: 5ae91d0ac1241bede3bb3723cde7af96c6b6cc8499a9f18306b6b9ca5b5cbd805c41c134a05a08d16e0495d9eac1b4b36e1431351fa4e3b612069623738ade5a
7
- data.tar.gz: c0e2f265b25767744e92729ba5f3e8d83b57d0d262fbf9c72a94288ab3a28543c8581bb5c5944959813f73bcafbb523f0b290c2f787e00ae02f3ab07028312a3
6
+ metadata.gz: 1c4b764c13b5231c2a19731003395933e059e64c30c7a8e1930ab7fa6c119b120cecfb815d78e30a32a653cdfa01b084187783170c44a762178b392f8c988a19
7
+ data.tar.gz: 7e395492e42da2b4f9b7d0abcf715ae6c5a253853316ec026b95116edd516bad87a77f17edd27e2d855fc1fb19ed77c46ee276751fc5ad7be567794dc66bb10c
data/.rubocop.yml CHANGED
@@ -62,6 +62,7 @@ Metrics/ClassLength:
62
62
  Metrics/ModuleLength:
63
63
  Exclude:
64
64
  - 'lib/calcpace/race_splits.rb'
65
+ - 'lib/calcpace/track_calculator.rb'
65
66
 
66
67
  # Allow both single and double quotes for strings
67
68
  Style/StringLiteralsInInterpolation:
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for GPS track calculations
4
+ #
5
+ # This module provides pure mathematical methods for computing distances,
6
+ # elevation changes, and pace splits from arrays of GPS coordinate points.
7
+ # It does not perform any file I/O or GPX parsing — callers are responsible
8
+ # for supplying arrays of hashes with the required keys.
9
+ #
10
+ # @example Calculate total distance of a track
11
+ # calc = Calcpace.new
12
+ # points = [
13
+ # { lat: -23.5505, lon: -46.6333 },
14
+ # { lat: -23.5510, lon: -46.6340 },
15
+ # { lat: -23.5520, lon: -46.6350 }
16
+ # ]
17
+ # calc.track_distance(points) #=> 0.17 (km)
18
+ #
19
+ # @example Calculate elevation gain and loss
20
+ # points = [
21
+ # { lat: -23.5505, lon: -46.6333, ele: 760.0 },
22
+ # { lat: -23.5510, lon: -46.6340, ele: 763.5 },
23
+ # { lat: -23.5515, lon: -46.6347, ele: 758.0 }
24
+ # ]
25
+ # calc.elevation_gain(points) #=> { gain: 3.5, loss: 5.5 }
26
+ module TrackCalculator
27
+ # Mean radius of the Earth in kilometers (IAU standard)
28
+ EARTH_RADIUS_KM = 6371.0
29
+
30
+ # Computes the great-circle distance between two GPS coordinates using
31
+ # the Haversine formula.
32
+ #
33
+ # The Haversine formula calculates the shortest distance over the Earth's
34
+ # surface between two points defined by latitude and longitude. It assumes
35
+ # a spherical Earth (error < 0.3% vs. WGS84 ellipsoid), which is accurate
36
+ # enough for running and cycling purposes.
37
+ #
38
+ # Formula:
39
+ # a = sin²(Δlat/2) + cos(lat1) × cos(lat2) × sin²(Δlon/2)
40
+ # c = 2 × atan2(√a, √(1−a))
41
+ # d = R × c
42
+ #
43
+ # @param lat1 [Numeric] latitude of first point in decimal degrees
44
+ # @param lon1 [Numeric] longitude of first point in decimal degrees
45
+ # @param lat2 [Numeric] latitude of second point in decimal degrees
46
+ # @param lon2 [Numeric] longitude of second point in decimal degrees
47
+ # @return [Float] distance in kilometers
48
+ # @raise [ArgumentError] if any coordinate is outside valid range (lat ±90, lon ±180)
49
+ #
50
+ # @example Distance between two points in São Paulo
51
+ # haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
52
+ # #=> 0.089 (km)
53
+ def haversine_distance(lat1, lon1, lat2, lon2)
54
+ validate_coordinates(lat1, lon1)
55
+ validate_coordinates(lat2, lon2)
56
+ haversine_km(lat1, lon1, lat2, lon2)
57
+ end
58
+
59
+ # Calculates the total distance of a GPS track by summing Haversine distances
60
+ # between consecutive points.
61
+ #
62
+ # @param points [Array<Hash>] array of points with :lat and :lon keys (String or Symbol)
63
+ # @return [Float] total distance in kilometers, rounded to 2 decimal places
64
+ # @raise [ArgumentError] if any point has coordinates outside valid range
65
+ #
66
+ # @example
67
+ # points = [
68
+ # { lat: -23.5505, lon: -46.6333 },
69
+ # { lat: -23.5510, lon: -46.6340 },
70
+ # { lat: -23.5520, lon: -46.6350 }
71
+ # ]
72
+ # track_distance(points) #=> 0.17
73
+ def track_distance(points)
74
+ return 0.0 if points.nil? || points.size < 2
75
+
76
+ total = points.each_cons(2).sum do |a, b|
77
+ haversine_distance(fetch_coord(a, :lat), fetch_coord(a, :lon),
78
+ fetch_coord(b, :lat), fetch_coord(b, :lon))
79
+ end
80
+
81
+ total.round(2)
82
+ end
83
+
84
+ # Calculates cumulative elevation gain and loss along a GPS track.
85
+ #
86
+ # Only consecutive pairs where both points have an :ele value are considered.
87
+ # Points missing :ele are silently skipped.
88
+ #
89
+ # @param points [Array<Hash>] array of points with optional :ele key (meters)
90
+ # @return [Hash] hash with :gain and :loss keys, both Floats rounded to 1 decimal
91
+ #
92
+ # @example
93
+ # points = [
94
+ # { lat: 0, lon: 0, ele: 100.0 },
95
+ # { lat: 0, lon: 0, ele: 105.0 },
96
+ # { lat: 0, lon: 0, ele: 102.0 }
97
+ # ]
98
+ # elevation_gain(points) #=> { gain: 5.0, loss: 3.0 }
99
+ def elevation_gain(points)
100
+ gain = 0.0
101
+ loss = 0.0
102
+ return { gain: gain, loss: loss } if points.nil? || points.size < 2
103
+
104
+ points.each_cons(2) do |a, b|
105
+ gain, loss = accumulate_elevation(gain, loss, fetch_ele(a), fetch_ele(b))
106
+ end
107
+
108
+ { gain: gain.round(1), loss: loss.round(1) }
109
+ end
110
+
111
+ # Calculates pace splits at regular distance intervals along a GPS track.
112
+ #
113
+ # Accumulates Haversine distance between consecutive points until the target
114
+ # split distance is reached, then records elapsed time and pace for that split.
115
+ # Any remaining distance at the end is included as a partial split.
116
+ #
117
+ # @param points [Array<Hash>] array of points with :lat, :lon, and :time keys.
118
+ # :time must respond to #to_f (Unix timestamp) or be a Time object.
119
+ # @param split_km [Numeric] split interval in kilometers (default: 1.0)
120
+ # @return [Array<Hash>] array of split hashes, each with:
121
+ # - :km [Float] cumulative distance at split end
122
+ # - :elapsed [Integer] elapsed seconds from start of track to end of split
123
+ # - :pace [String] pace for this split in MM:SS format
124
+ # @raise [ArgumentError] if split_km is not positive
125
+ # @raise [ArgumentError] if any point is missing a :time key
126
+ #
127
+ # @example 5 km track with 1 km splits
128
+ # calc.track_splits(points, 1.0)
129
+ # #=> [
130
+ # { km: 1.0, elapsed: 312, pace: "05:12" },
131
+ # { km: 2.0, elapsed: 624, pace: "05:12" },
132
+ # ...
133
+ # ]
134
+ def track_splits(points, split_km = 1.0)
135
+ raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
136
+ return [] if points.nil? || points.size < 2
137
+
138
+ validate_points_have_time(points)
139
+ collect_splits(points, split_km)
140
+ end
141
+
142
+ private
143
+
144
+ def haversine_km(lat1, lon1, lat2, lon2)
145
+ dlat = deg_to_rad(lat2 - lat1)
146
+ dlon = deg_to_rad(lon2 - lon1)
147
+ a = haversine_a(dlat, dlon, lat1, lat2)
148
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
149
+ EARTH_RADIUS_KM * c
150
+ end
151
+
152
+ def haversine_a(dlat, dlon, lat1, lat2)
153
+ (Math.sin(dlat / 2)**2) +
154
+ (Math.cos(deg_to_rad(lat1)) * Math.cos(deg_to_rad(lat2)) *
155
+ (Math.sin(dlon / 2)**2))
156
+ end
157
+
158
+ def deg_to_rad(degrees)
159
+ degrees * Math::PI / 180.0
160
+ end
161
+
162
+ def validate_coordinates(lat, lon)
163
+ unless lat.is_a?(Numeric) && lat >= -90 && lat <= 90
164
+ raise ArgumentError, "Invalid latitude: #{lat}. Must be between -90 and 90."
165
+ end
166
+
167
+ return if lon.is_a?(Numeric) && lon >= -180 && lon <= 180
168
+
169
+ raise ArgumentError, "Invalid longitude: #{lon}. Must be between -180 and 180."
170
+ end
171
+
172
+ def accumulate_elevation(gain, loss, ele_a, ele_b)
173
+ return [gain, loss] if ele_a.nil? || ele_b.nil?
174
+
175
+ diff = ele_b - ele_a
176
+ if diff.positive?
177
+ [gain + diff, loss]
178
+ else
179
+ [gain, loss + diff.abs]
180
+ end
181
+ end
182
+
183
+ def fetch_coord(point, key)
184
+ point[key] || point[key.to_s]
185
+ end
186
+
187
+ def fetch_ele(point)
188
+ val = point[:ele] || point['ele']
189
+ val&.to_f
190
+ end
191
+
192
+ def validate_points_have_time(points)
193
+ points.each_with_index do |pt, i|
194
+ next if pt[:time] || pt['time']
195
+
196
+ raise ArgumentError, "Point at index #{i} is missing :time key required for splits"
197
+ end
198
+ end
199
+
200
+ def point_time(point)
201
+ t = point[:time] || point['time']
202
+ t.respond_to?(:to_f) ? t.to_f : t
203
+ end
204
+
205
+ def interpolate_time(point_a, point_b, segment_km, distance_into_segment)
206
+ return point_time(point_a) if segment_km.zero?
207
+
208
+ t_a = point_time(point_a)
209
+ t_b = point_time(point_b)
210
+ t_a + ((t_b - t_a) * (distance_into_segment / segment_km))
211
+ end
212
+
213
+ def seconds_to_pace(seconds, km)
214
+ return '00:00' if km.zero?
215
+
216
+ pace_seconds = (seconds.to_f / km).round
217
+ format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
218
+ end
219
+
220
+ def collect_splits(points, split_km)
221
+ state = { splits: [], start_time: point_time(points.first),
222
+ split_start_time: point_time(points.first),
223
+ accumulated_km: 0.0, split_number: 1 }
224
+
225
+ points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
226
+ append_partial_split(points.last, split_km, state)
227
+ state[:splits]
228
+ end
229
+
230
+ def process_segment(point_a, point_b, split_km, state)
231
+ segment_km = haversine_distance(fetch_coord(point_a, :lat), fetch_coord(point_a, :lon),
232
+ fetch_coord(point_b, :lat), fetch_coord(point_b, :lon))
233
+ state[:accumulated_km] += segment_km
234
+
235
+ while state[:accumulated_km] >= split_km * state[:split_number]
236
+ record_split(point_a, point_b, segment_km, split_km, state)
237
+ end
238
+ end
239
+
240
+ def record_split(point_a, point_b, segment_km, split_km, state)
241
+ offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km)
242
+ boundary_time = interpolate_time(point_a, point_b, segment_km, offset)
243
+ state[:splits] << build_split_entry(boundary_time, split_km, state)
244
+ state[:split_start_time] = boundary_time
245
+ state[:split_number] += 1
246
+ end
247
+
248
+ def build_split_entry(boundary_time, split_km, state)
249
+ split_elapsed = (boundary_time - state[:split_start_time]).round
250
+ {
251
+ km: (split_km * state[:split_number]).round(2),
252
+ elapsed: (boundary_time - state[:start_time]).round,
253
+ pace: seconds_to_pace(split_elapsed, split_km)
254
+ }
255
+ end
256
+
257
+ def append_partial_split(last_point, split_km, state)
258
+ remaining_km = state[:accumulated_km] - (split_km * (state[:split_number] - 1))
259
+ return unless remaining_km > 0.001
260
+
261
+ last_time = point_time(last_point)
262
+ state[:splits] << {
263
+ km: state[:accumulated_km].round(2),
264
+ elapsed: (last_time - state[:start_time]).round,
265
+ pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km)
266
+ }
267
+ end
268
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.9.1'
4
+ VERSION = '1.9.2'
5
5
  end
data/lib/calcpace.rb CHANGED
@@ -10,6 +10,7 @@ require_relative 'calcpace/pace_calculator'
10
10
  require_relative 'calcpace/pace_converter'
11
11
  require_relative 'calcpace/race_predictor'
12
12
  require_relative 'calcpace/race_splits'
13
+ require_relative 'calcpace/track_calculator'
13
14
 
14
15
  # Calcpace - A Ruby gem for pace, distance, and time calculations
15
16
  #
@@ -42,6 +43,7 @@ class Calcpace
42
43
  include PaceConverter
43
44
  include RacePredictor
44
45
  include RaceSplits
46
+ include TrackCalculator
45
47
 
46
48
  # Creates a new Calcpace instance
47
49
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calcpace
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -40,6 +40,7 @@ files:
40
40
  - lib/calcpace/pace_converter.rb
41
41
  - lib/calcpace/race_predictor.rb
42
42
  - lib/calcpace/race_splits.rb
43
+ - lib/calcpace/track_calculator.rb
43
44
  - lib/calcpace/version.rb
44
45
  homepage: https://github.com/0jonjo/calcpace
45
46
  licenses: