tcxread 0.1.5 → 0.2.0

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.
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