Skip to content

Commit b633c44

Browse files
committed
feat: add TrackCalculator module — haversine distance, elevation gain, track splits (v1.9.2)
Implements GPS track calculations as pure Ruby math (no I/O, no parsing): - haversine_distance: great-circle distance between two coordinates - track_distance: total distance of a GPS track (sum of consecutive haversine segments) - elevation_gain: cumulative gain/loss along a track (skips points without :ele) - track_splits: pace splits at configurable km intervals with time interpolation 29 tests, 46 assertions. Full suite: 197 runs, 0 failures.
1 parent 20a2f88 commit b633c44

5 files changed

Lines changed: 519 additions & 1 deletion

File tree

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Metrics/ClassLength:
6262
Metrics/ModuleLength:
6363
Exclude:
6464
- 'lib/calcpace/race_splits.rb'
65+
- 'lib/calcpace/track_calculator.rb'
6566

6667
# Allow both single and double quotes for strings
6768
Style/StringLiteralsInInterpolation:

lib/calcpace.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
require_relative 'calcpace/pace_converter'
1111
require_relative 'calcpace/race_predictor'
1212
require_relative 'calcpace/race_splits'
13+
require_relative 'calcpace/track_calculator'
1314

1415
# Calcpace - A Ruby gem for pace, distance, and time calculations
1516
#
@@ -42,6 +43,7 @@ class Calcpace
4243
include PaceConverter
4344
include RacePredictor
4445
include RaceSplits
46+
include TrackCalculator
4547

4648
# Creates a new Calcpace instance
4749
#

lib/calcpace/track_calculator.rb

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/calcpace/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
class Calcpace
4-
VERSION = '1.9.1'
4+
VERSION = '1.9.2'
55
end

0 commit comments

Comments
 (0)