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 +4 -4
- data/.rubocop.yml +1 -0
- data/lib/calcpace/track_calculator.rb +268 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03d52df60dcc776004ee27c76c98010904d0bc6ecfc839e81e4d8aa19e299b8d
|
|
4
|
+
data.tar.gz: d25c779a355310c08574b8b5c16ed9d946515fbe55c70c766569f13a57774d5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c4b764c13b5231c2a19731003395933e059e64c30c7a8e1930ab7fa6c119b120cecfb815d78e30a32a653cdfa01b084187783170c44a762178b392f8c988a19
|
|
7
|
+
data.tar.gz: 7e395492e42da2b4f9b7d0abcf715ae6c5a253853316ec026b95116edd516bad87a77f17edd27e2d855fc1fb19ed77c46ee276751fc5ad7be567794dc66bb10c
|
data/.rubocop.yml
CHANGED
|
@@ -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
|
data/lib/calcpace/version.rb
CHANGED
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.
|
|
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:
|