tcxread 0.1.5 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tcxread.rb +155 -220
  3. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0f94a5a07acd01e1c6b0419324bbef97912e0d7e67b3f2b1dc18d50336dd7e2
4
- data.tar.gz: 776953d7610ffa6c6903449332eb1bf36ff2a7fb54c5fa10dde8e591ecefd3df
3
+ metadata.gz: 467aa1170c1d1e4187ad0bf62cdf274e3323427e1a17b373761d9e15344f3246
4
+ data.tar.gz: 3bb6690e5169d24ea3a18cbcea16af923a3d203a1f230364b5db2e68363ced11
5
5
  SHA512:
6
- metadata.gz: dc1240984ccb04288e3790d69529eea52269f16888e1b6d8906017b42252ce98eeef44240b729ed1358444e2f0d3f7ce377f3a49123ea5c85c21493a2438cc77
7
- data.tar.gz: 99863f90228c50f2590709105fab6d55395c26462bec7b2ba2b11c429f2e85453867583fdcb964d83599f669bbc1dc991fa6ca8a0c5eff6aba8a3681007d1b48
6
+ metadata.gz: 8fa40c28c9ead5bbdbdb3d3c7a79958f7145f6b53398771af1776be3e6d44c25d2e0e9ec8165026d4c54789dccad0550fd69e882e1471dec322feb79a3a3c059
7
+ data.tar.gz: ff9edd51b84495ce42b3db1ea53a807f23df2c97b264524f5f274a9b2b9a815efcedd6d0e7c2bc540268a4655a85c3cdd183ce1aa1f732edc22476bcbf53e157
data/lib/tcxread.rb CHANGED
@@ -1,11 +1,49 @@
1
1
  require "nokogiri"
2
2
 
3
+ # The `TCXRead` class processes and analyzes data from a TCX (Training Center XML) file.
4
+ # It extracts key metrics such as distance, time, calories, ascent/descent, altitude, heart rate,
5
+ # power (watts), cadence, and speed from the activities recorded in the TCX file.
6
+ #
7
+ # Reference (see also):
8
+ # I. Jr. Fister, L. Lukač, A. Rajšp, I. Fister, L. Pečnik, and D. Fister,
9
+ # "A minimalistic toolbox for extracting features from sport activity files,"
10
+ # 2021 IEEE 25th International Conference on Intelligent Engineering Systems (INES), 2021,
11
+ # pp. 121-126, doi: 10.1109/INES52918.2021.9512927.
3
12
  class TCXRead
13
+ # @!attribute [r] total_distance_meters
14
+ # @return [Float] The total distance covered in meters.
15
+ # @!attribute [r] total_time_seconds
16
+ # @return [Float] The total time of activities in seconds.
17
+ # @!attribute [r] total_calories
18
+ # @return [Integer] The total calories burned.
19
+ # @!attribute [r] total_ascent
20
+ # @return [Float] The total ascent in meters.
21
+ # @!attribute [r] total_descent
22
+ # @return [Float] The total descent in meters.
23
+ # @!attribute [r] max_altitude
24
+ # @return [Float] The maximum altitude reached in meters.
25
+ # @!attribute [r] average_heart_rate
26
+ # @return [Float] The average heart rate in beats per minute.
27
+ # @!attribute [r] max_watts
28
+ # @return [String, Float] The maximum power output in watts, or 'NA' if unavailable.
29
+ # @!attribute [r] average_watts
30
+ # @return [String, Float] The average power output in watts, or 'NA' if unavailable.
31
+ # @!attribute [r] average_cadence_all
32
+ # @return [Float] The average cadence in RPM.
33
+ # @!attribute [r] average_cadence_biking
34
+ # @return [Float] The average cadence for the whole activity in RPM.
35
+ # @!attribute [r] average_speed_all
36
+ # @return [Float] The average speed for the whole activity in meters per second.
37
+ # @!attribute [r] average_speed_moving
38
+ # @return [Float] The average speed while moving in meters per second.
39
+
4
40
  attr_reader :total_distance_meters, :total_time_seconds, :total_calories,
5
41
  :total_ascent, :total_descent, :max_altitude, :average_heart_rate,
6
42
  :max_watts, :average_watts, :average_cadence_all, :average_cadence_biking,
7
43
  :average_speed_all, :average_speed_moving
8
44
 
45
+ # Initializes the TCXRead object and parses the TCX file.
46
+ # @param file_path [String] The file path of the TCX file to process.
9
47
  def initialize(file_path)
10
48
  @file_path = file_path
11
49
  @doc = Nokogiri::XML(File.open(file_path))
@@ -28,276 +66,173 @@ class TCXRead
28
66
  parse
29
67
  end
30
68
 
69
+ # Parses the TCX file and computes metrics for all activities.
31
70
  def parse
32
71
  activities = parse_activities
33
- if activities.any?
34
- @total_time_seconds = activities.sum { |activity| activity[:total_time_seconds] }
35
- @total_distance_meters = activities.sum { |activity| activity[:total_distance_meters] }
36
- @total_calories = activities.sum { |activity| activity[:total_calories] }
37
- @total_ascent, @total_descent, @max_altitude = calculate_ascent_descent_and_max_altitude_from_activities(activities)
38
- @average_heart_rate = calculate_average_heart_rate_from_activities(activities)
39
- @max_watts, @average_watts = calculate_watts_from_activities(activities)
40
- cadence_results = calculate_average_cadence_from_activities(activities)
41
- @average_cadence_all = cadence_results[:average_cadence_all]
42
- @average_cadence_biking = cadence_results[:average_cadence_biking]
43
- speed_results = calculate_average_speed_from_activities(activities)
44
- @average_speed_all = speed_results[:average_speed_all]
45
- @average_speed_moving = speed_results[:average_speed_moving]
46
- end
72
+ return if activities.empty?
73
+
74
+ @total_time_seconds = activities.sum { |activity| activity[:total_time_seconds] }
75
+ @total_distance_meters = activities.sum { |activity| activity[:total_distance_meters] }
76
+ @total_calories = activities.sum { |activity| activity[:total_calories] }
77
+
78
+ @total_ascent, @total_descent, @max_altitude = calculate_ascent_descent_and_max_altitude_from_activities(activities)
79
+ @average_heart_rate = calculate_average(:heart_rate, activities)
80
+ @max_watts, @average_watts = calculate_watts_from_activities(activities)
47
81
 
48
- { activities: activities }
82
+ cadence_results = calculate_average_cadence_from_activities(activities)
83
+ @average_cadence_all = cadence_results[:average_cadence_all]
84
+ @average_cadence_biking = cadence_results[:average_cadence_biking]
85
+
86
+ speed_results = calculate_average_speed_from_activities(activities)
87
+ @average_speed_all = speed_results[:average_speed_all]
88
+ @average_speed_moving = speed_results[:average_speed_moving]
49
89
  end
50
90
 
51
91
  private
52
92
 
93
+ # Parses activities from the TCX file.
94
+ # @return [Array<Hash>] An array of parsed activity data.
53
95
  def parse_activities
54
- activities = []
55
- @doc.xpath('//xmlns:Activities/xmlns:Activity').each do |activity|
56
- laps = parse_laps(activity)
57
- total_time_seconds = laps.sum { |lap| lap[:total_time_seconds] }
58
- total_distance_meters = laps.sum { |lap| lap[:distance_meters] }
59
- total_calories = laps.sum { |lap| lap[:calories] }
60
- total_ascent, total_descent, max_altitude = calculate_ascent_descent_and_max_altitude(laps)
61
- average_heart_rate = calculate_average_heart_rate(laps)
96
+ @doc.xpath('//xmlns:Activities/xmlns:Activity').map do |activity|
97
+ laps = activity.xpath('xmlns:Lap').map { |lap| parse_lap(lap) }
62
98
 
63
- activities << {
99
+ {
64
100
  sport: activity.attr('Sport'),
65
101
  id: activity.xpath('xmlns:Id').text,
66
102
  laps: laps,
67
- total_time_seconds: total_time_seconds,
68
- total_distance_meters: total_distance_meters,
69
- total_calories: total_calories,
70
- total_ascent: total_ascent,
71
- total_descent: total_descent,
72
- max_altitude: max_altitude,
73
- average_heart_rate: average_heart_rate
103
+ total_time_seconds: laps.sum { |lap| lap[:total_time_seconds] },
104
+ total_distance_meters: laps.sum { |lap| lap[:distance_meters] },
105
+ total_calories: laps.sum { |lap| lap[:calories] },
106
+ total_ascent: laps.sum { |lap| lap[:total_ascent] },
107
+ total_descent: laps.sum { |lap| lap[:total_descent] },
108
+ max_altitude: laps.map { |lap| lap[:max_altitude] }.max,
109
+ average_heart_rate: calculate_average(:heart_rate, laps)
74
110
  }
75
111
  end
76
- activities
77
112
  end
78
113
 
79
- def parse_laps(activity)
80
- laps = []
81
- activity.xpath('xmlns:Lap').each do |lap|
82
- laps << {
83
- start_time: lap.attr('StartTime'),
84
- total_time_seconds: lap.xpath('xmlns:TotalTimeSeconds').text.to_f,
85
- distance_meters: lap.xpath('xmlns:DistanceMeters').text.to_f,
86
- maximum_speed: lap.xpath('xmlns:MaximumSpeed').text.to_f,
87
- calories: lap.xpath('xmlns:Calories').text.to_i,
88
- average_heart_rate: lap.xpath('xmlns:AverageHeartRateBpm/xmlns:Value').text.to_i,
89
- maximum_heart_rate: lap.xpath('xmlns:MaximumHeartRateBpm/xmlns:Value').text.to_i,
90
- intensity: lap.xpath('xmlns:Intensity').text,
91
- cadence: lap.xpath('xmlns:Cadence').text.to_i,
92
- trigger_method: lap.xpath('xmlns:TriggerMethod').text,
93
- tracks: parse_tracks(lap)
94
- }
95
- end
96
- laps
97
- end
114
+ # Parses a single lap from the TCX file.
115
+ # @param lap [Nokogiri::XML::Node] The lap node to parse.
116
+ # @return [Hash] The parsed lap data.
117
+ def parse_lap(lap)
118
+ trackpoints = lap.xpath('xmlns:Track/xmlns:Trackpoint').map { |tp| parse_trackpoint(tp) }
98
119
 
99
- def parse_tracks(lap)
100
- tracks = []
101
- lap.xpath('xmlns:Track').each do |track|
102
- trackpoints = []
103
- track.xpath('xmlns:Trackpoint').each do |trackpoint|
104
- trackpoints << {
105
- time: trackpoint.xpath('xmlns:Time').text,
106
- position: parse_position(trackpoint),
107
- altitude_meters: trackpoint.xpath('xmlns:AltitudeMeters').text.to_f,
108
- distance_meters: trackpoint.xpath('xmlns:DistanceMeters').text.to_f,
109
- heart_rate: trackpoint.xpath('xmlns:HeartRateBpm/xmlns:Value').text.to_i,
110
- cadence: trackpoint.xpath('xmlns:Cadence').text.to_i,
111
- sensor_state: trackpoint.xpath('xmlns:SensorState').text,
112
- watts: trackpoint.xpath('xmlns:Extensions/ns3:TPX/ns3:Watts').text.to_f,
113
- speed: trackpoint.xpath('xmlns:Extensions/ns3:TPX/ns3:Speed').text.to_f
114
- }
115
- end
116
- tracks << trackpoints
117
- end
118
- tracks
120
+ {
121
+ start_time: lap.attr('StartTime'),
122
+ total_time_seconds: lap.xpath('xmlns:TotalTimeSeconds').text.to_f,
123
+ distance_meters: lap.xpath('xmlns:DistanceMeters').text.to_f,
124
+ calories: lap.xpath('xmlns:Calories').text.to_i,
125
+ total_ascent: calculate_ascent(trackpoints),
126
+ total_descent: calculate_descent(trackpoints),
127
+ max_altitude: trackpoints.map { |tp| tp[:altitude_meters] }.max || 0,
128
+ trackpoints: trackpoints || []
129
+ }
119
130
  end
120
131
 
121
- def parse_position(trackpoint)
122
- position = trackpoint.at_xpath('xmlns:Position')
123
- return nil unless position
124
-
132
+ # Parses a single trackpoint from the TCX file.
133
+ # @param trackpoint [Nokogiri::XML::Node] The trackpoint node to parse.
134
+ # @return [Hash] The parsed trackpoint data.
135
+ def parse_trackpoint(trackpoint)
125
136
  {
126
- latitude: position.xpath('xmlns:LatitudeDegrees').text.to_f,
127
- longitude: position.xpath('xmlns:LongitudeDegrees').text.to_f
137
+ time: trackpoint.xpath('xmlns:Time').text,
138
+ altitude_meters: trackpoint.xpath('xmlns:AltitudeMeters').text.to_f,
139
+ distance_meters: trackpoint.xpath('xmlns:DistanceMeters').text.to_f,
140
+ heart_rate: trackpoint.xpath('xmlns:HeartRateBpm/xmlns:Value').text.to_i,
141
+ cadence: trackpoint.xpath('xmlns:Cadence').text.to_i,
142
+ watts: trackpoint.xpath('xmlns:Extensions/ns3:TPX/ns3:Watts').text.to_f,
143
+ speed: trackpoint.xpath('xmlns:Extensions/ns3:TPX/ns3:Speed').text.to_f
128
144
  }
129
145
  end
130
146
 
131
- def calculate_ascent_descent_and_max_altitude(laps)
132
- total_ascent = 0.0
133
- total_descent = 0.0
134
- max_altitude = -Float::INFINITY
147
+ # Calculates total ascent from an array of trackpoints.
148
+ # @param trackpoints [Array<Hash>] An array of trackpoint data.
149
+ # @return [Float] The total ascent in meters.
150
+ def calculate_ascent(trackpoints)
135
151
  previous_altitude = nil
136
-
137
- laps.each do |lap|
138
- lap[:tracks].flatten.each do |trackpoint|
139
- altitude = trackpoint[:altitude_meters]
140
- max_altitude = altitude if altitude > max_altitude
141
-
142
- if previous_altitude
143
- altitude_change = altitude - previous_altitude
144
- if altitude_change > 0
145
- total_ascent += altitude_change
146
- elsif altitude_change < 0
147
- total_descent += altitude_change.abs
148
- end
149
- end
150
-
151
- previous_altitude = altitude
152
- end
152
+ trackpoints.sum do |tp|
153
+ altitude = tp[:altitude_meters]
154
+ ascent = previous_altitude && altitude > previous_altitude ? altitude - previous_altitude : 0
155
+ previous_altitude = altitude
156
+ ascent
153
157
  end
154
-
155
- [total_ascent, total_descent, max_altitude]
156
158
  end
157
159
 
158
- def calculate_ascent_descent_and_max_altitude_from_activities(activities)
159
- total_ascent = 0.0
160
- total_descent = 0.0
161
- max_altitude = -Float::INFINITY
162
-
163
- activities.each do |activity|
164
- total_ascent += activity[:total_ascent]
165
- total_descent += activity[:total_descent]
166
- max_altitude = activity[:max_altitude] if activity[:max_altitude] > max_altitude
160
+ # Calculates total descent from an array of trackpoints.
161
+ # @param trackpoints [Array<Hash>] An array of trackpoint data.
162
+ # @return [Float] The total descent in meters.
163
+ def calculate_descent(trackpoints)
164
+ previous_altitude = nil
165
+ trackpoints.sum do |tp|
166
+ altitude = tp[:altitude_meters]
167
+ descent = previous_altitude && altitude < previous_altitude ? previous_altitude - altitude : 0
168
+ previous_altitude = altitude
169
+ descent
167
170
  end
168
-
169
- [total_ascent, total_descent, max_altitude]
170
171
  end
171
172
 
172
- def calculate_average_heart_rate(laps)
173
- total_heart_rate = 0
174
- heart_rate_count = 0
175
-
176
- laps.each do |lap|
177
- lap[:tracks].flatten.each do |trackpoint|
178
- heart_rate = trackpoint[:heart_rate]
179
- if heart_rate > 0
180
- total_heart_rate += heart_rate
181
- heart_rate_count += 1
182
- end
183
- end
184
- end
185
-
186
- heart_rate_count > 0 ? total_heart_rate.to_f / heart_rate_count : 0.0
173
+ # Calculates ascent, descent, and maximum altitude from activities.
174
+ # @param activities [Array<Hash>] An array of activity data.
175
+ # @return [Array<Float>] Total ascent, total descent, and maximum altitude.
176
+ def calculate_ascent_descent_and_max_altitude_from_activities(activities)
177
+ total_ascent = activities.sum { |activity| activity[:total_ascent] }
178
+ total_descent = activities.sum { |activity| activity[:total_descent] }
179
+ max_altitude = activities.map { |activity| activity[:max_altitude] }.max
180
+ [total_ascent, total_descent, max_altitude]
187
181
  end
188
182
 
189
- def calculate_average_heart_rate_from_activities(activities)
190
- total_heart_rate = 0
191
- heart_rate_count = 0
192
-
193
- activities.each do |activity|
194
- activity[:laps].each do |lap|
195
- lap[:tracks].flatten.each do |trackpoint|
196
- heart_rate = trackpoint[:heart_rate]
197
- if heart_rate > 0
198
- total_heart_rate += heart_rate
199
- heart_rate_count += 1
200
- end
201
- end
202
- end
203
- end
204
-
205
- heart_rate_count > 0 ? total_heart_rate.to_f / heart_rate_count : 0.0
183
+ # Calculates the average value of a metric across laps or activities.
184
+ # @param metric [Symbol] The metric to average (e.g., :heart_rate).
185
+ # @param laps_or_activities [Array<Hash>] An array of lap or activity data.
186
+ # @return [Float] The average value of the metric.
187
+ def calculate_average(metric, laps_or_activities)
188
+ values = laps_or_activities.flat_map do |lap|
189
+ next [] unless lap[:trackpoints]
190
+ lap[:trackpoints].map { |tp| tp[metric] }
191
+ end.compact
192
+
193
+ values.any? ? values.sum.to_f / values.size : 0.0
206
194
  end
207
195
 
196
+ # Calculates power metrics from activities.
197
+ # @param activities [Array<Hash>] An array of activity data.
198
+ # @return [Array<String, Float>] Maximum and average power output.
208
199
  def calculate_watts_from_activities(activities)
209
- max_watts = 0
210
- total_watts = 0
211
- watts_count = 0
212
-
213
- activities.each do |activity|
214
- activity[:laps].each do |lap|
215
- lap[:tracks].flatten.each do |trackpoint|
216
- watts = trackpoint[:watts]
217
- if watts > 0
218
- total_watts += watts
219
- watts_count += 1
220
- max_watts = watts if watts > max_watts
221
- end
222
- end
200
+ watts = activities.flat_map do |activity|
201
+ activity[:laps].flat_map do |lap|
202
+ lap[:trackpoints]&.map { |tp| tp[:watts] } || []
223
203
  end
224
- end
204
+ end.compact
225
205
 
226
- if watts_count > 0
227
- average_watts = total_watts.to_f / watts_count
228
- max_watts = max_watts
206
+ if watts.any?
207
+ max_watts = watts.max
208
+ avg_watts = watts.sum.to_f / watts.size
229
209
  else
230
- average_watts = 'NA'
231
210
  max_watts = 'NA'
211
+ avg_watts = 'NA'
232
212
  end
233
213
 
234
- [max_watts, average_watts]
214
+ [max_watts, avg_watts]
235
215
  end
236
216
 
217
+ # Calculates average cadence metrics from activities.
218
+ # @param activities [Array<Hash>] An array of activity data.
219
+ # @return [Hash] Average cadence metrics for all activities and biking only.
237
220
  def calculate_average_cadence_from_activities(activities)
238
- total_cadence_all = 0
239
- total_cadence_biking = 0
240
- cadence_count_all = 0
241
- cadence_count_biking = 0
242
-
243
- activities.each do |activity|
244
- activity[:laps].each do |lap|
245
- lap[:tracks].flatten.each do |trackpoint|
246
- cadence = trackpoint[:cadence]
247
- total_cadence_all += cadence
248
- cadence_count_all += 1
249
-
250
- if cadence > 0
251
- total_cadence_biking += cadence
252
- cadence_count_biking += 1
253
- end
254
- end
255
- end
256
- end
257
-
258
- average_cadence_all = cadence_count_all > 0 ? total_cadence_all.to_f / cadence_count_all : 0.0
259
- average_cadence_biking = cadence_count_biking > 0 ? total_cadence_biking.to_f / cadence_count_biking : 0.0
260
-
221
+ cadences = activities.flat_map { |activity| activity[:laps].flat_map { |lap| lap[:trackpoints].map { |tp| tp[:cadence] } if lap[:trackpoints] } }.compact
261
222
  {
262
- average_cadence_all: average_cadence_all,
263
- average_cadence_biking: average_cadence_biking
223
+ average_cadence_all: cadences.any? ? cadences.sum.to_f / cadences.size : 0.0,
224
+ average_cadence_biking: cadences.reject(&:zero?).any? ? cadences.reject(&:zero?).sum.to_f / cadences.reject(&:zero?).size : 0.0
264
225
  }
265
226
  end
266
227
 
267
- # Calculates the average speed from the activities.
268
- #
269
- # @param activities [Array<Hash>] an array of activity hashes.
270
- # @return [Hash] a hash containing average speed including zeros and average speed while moving.
228
+ # Calculates average speed metrics from activities.
229
+ # @param activities [Array<Hash>] An array of activity data.
230
+ # @return [Hash] Average speed metrics for the whole activity and moving activities only.
271
231
  def calculate_average_speed_from_activities(activities)
272
- total_speed_all = 0
273
- total_speed_moving = 0
274
- speed_count_all = 0
275
- speed_count_moving = 0
276
-
277
- activities.each do |activity|
278
- activity[:laps].each do |lap|
279
- lap[:tracks].flatten.each do |trackpoint|
280
- speed = trackpoint[:speed]
281
-
282
- if speed
283
- total_speed_all += speed
284
- speed_count_all += 1
285
-
286
- if speed > 0
287
- total_speed_moving += speed
288
- speed_count_moving += 1
289
- end
290
- end
291
- end
292
- end
293
- end
294
-
295
- average_speed_all = speed_count_all > 0 ? total_speed_all.to_f / speed_count_all : 0.0
296
- average_speed_moving = speed_count_moving > 0 ? total_speed_moving.to_f / speed_count_moving : 0.0
297
-
232
+ speeds = activities.flat_map { |activity| activity[:laps].flat_map { |lap| lap[:trackpoints].map { |tp| tp[:speed] } if lap[:trackpoints] } }.compact
298
233
  {
299
- average_speed_all: average_speed_all,
300
- average_speed_moving: average_speed_moving
234
+ average_speed_all: speeds.any? ? speeds.sum.to_f / speeds.size : 0.0,
235
+ average_speed_moving: speeds.reject(&:zero?).any? ? speeds.reject(&:zero?).sum.to_f / speeds.reject(&:zero?).size : 0.0
301
236
  }
302
237
  end
303
238
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tcxread
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - firefly-cpp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-20 00:00:00.000000000 Z
11
+ date: 2024-12-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -56,7 +56,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
56
56
  - !ruby/object:Gem::Version
57
57
  version: '0'
58
58
  requirements: []
59
- rubygems_version: 3.5.11
59
+ rubygems_version: 3.5.22
60
60
  signing_key:
61
61
  specification_version: 4
62
62
  summary: tcx reader/parser in Ruby