git-fit 0.10.4 → 0.10.5

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.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module GitFit
6
+ module Dedup
7
+ class Matcher
8
+ WEIGHTS = { time: 0.35, distance: 0.35, duration: 0.15, category: 0.15 }.freeze
9
+ TRAJECTORY_WEIGHTS = {
10
+ dtw_similarity: 0.30,
11
+ overlap_score: 0.25,
12
+ hausdorff_score: 0.25,
13
+ distance_match: 0.20,
14
+ }.freeze
15
+
16
+ def self.effective_duration(act)
17
+ [act[:moving_time].to_f, act[:elapsed_time].to_f].max
18
+ end
19
+
20
+ def self.external_id_match(a, b)
21
+ a_eid = a[:external_id]
22
+ b_eid = b[:external_id]
23
+ return nil if a_eid.nil? || b_eid.nil?
24
+ return nil if a_eid.empty? || b_eid.empty?
25
+
26
+ a_normalized = a_eid.sub(%r{^strava://activities/}, '')
27
+ b_normalized = b_eid.sub(%r{^strava://activities/}, '')
28
+
29
+ { confidence: 1.0, flag: 'derived', source: 'external_id' } if a_normalized == b_normalized
30
+ end
31
+
32
+ def self.match(a, b)
33
+ exact = external_id_match(a, b)
34
+ return exact[:confidence] if exact
35
+
36
+ t_diff = time_diff_abs(a[:start_date], b[:start_date])
37
+ max_diff = [300, (a[:elapsed_time] || 0).to_i, (b[:elapsed_time] || 0).to_i].max
38
+ return 0.0 if t_diff > max_diff
39
+
40
+ t_score = 1.0 - t_diff / max_diff.to_f
41
+ d_score = distance_score(a[:distance].to_f, b[:distance].to_f)
42
+ return 0.0 unless d_score
43
+
44
+ dur_score = duration_score(effective_duration(a), effective_duration(b))
45
+ cat_score = a[:sport_category] == b[:sport_category] ? 1.0 : -0.67
46
+
47
+ result = t_score * WEIGHTS[:time] +
48
+ d_score * WEIGHTS[:distance] +
49
+ dur_score * WEIGHTS[:duration] +
50
+ cat_score * WEIGHTS[:category]
51
+ result.round(4)
52
+ end
53
+
54
+ def self.time_diff_abs(t1, t2)
55
+ t1 = t1.to_s
56
+ t2 = t2.to_s
57
+ return 9999 if t1.empty? || t2.empty?
58
+ (Time.parse(t1) - Time.parse(t2)).abs
59
+ rescue StandardError
60
+ 9999
61
+ end
62
+
63
+ def self.distance_score(d1, d2)
64
+ max = [d1, d2].max
65
+ return nil if max <= 0
66
+ diff = (d1 - d2).abs
67
+ return nil if diff > max * 0.15
68
+ 1.0 - diff / (max * 0.15)
69
+ end
70
+
71
+ def self.duration_score(d1, d2)
72
+ max = [d1, d2].max
73
+ return 0.0 if max <= 0
74
+ diff = (d1 - d2).abs
75
+ return 0.0 if diff > max * 0.15
76
+ 1.0 - diff / (max * 0.15)
77
+ end
78
+
79
+ def self.trajectory_score(scores)
80
+ return 0.0 if scores.nil? || scores.empty?
81
+ weighted = TRAJECTORY_WEIGHTS.sum do |metric, weight|
82
+ score = scores[metric]
83
+ next 0.0 unless score.is_a?(Numeric) && score >= 0.0 && score <= 1.0
84
+ score * weight
85
+ end
86
+ weighted.round(4)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require_relative 'matcher'
6
+ require_relative 'trajectory'
7
+ require_relative 'group_consolidator'
8
+ require_relative 'log_writer'
9
+
10
+ module GitFit
11
+ module Dedup
12
+ class Service
13
+ def initialize(db, incremental: false, log_path: nil, verbose: false)
14
+ @db = db
15
+ @trajectory = Trajectory.new(db)
16
+ @incremental = incremental
17
+ @log_path = log_path
18
+ @verbose = verbose
19
+ @updates = {}
20
+ @matched_pairs = []
21
+ end
22
+
23
+ def run
24
+ activities = activities_scope.all
25
+ return 0 if activities.size < 2
26
+ puts "DedupService: scanning #{activities.size} activities" if @verbose
27
+
28
+ start_time = Time.now.utc
29
+ matched = 0
30
+
31
+ activities.each_with_index do |a, i|
32
+ a_start = parse_time(a[:start_date])
33
+ next unless a_start
34
+ a_range = activity_range(a)
35
+
36
+ (i + 1...activities.size).each do |j|
37
+ b = activities[j]
38
+ b_start = parse_time(b[:start_date])
39
+ next unless b_start
40
+ break if b_start > (a_range[:end] + 86400)
41
+
42
+ score = Matcher.match(a, b)
43
+ next unless score && score >= 0.15
44
+
45
+ flag = classify_flag(a, b, score)
46
+ next unless flag
47
+
48
+ append_info(a[:run_id], b[:source], b[:run_id], score, flag)
49
+ append_info(b[:run_id], a[:source], a[:run_id], score, flag)
50
+ @matched_pairs << { a: a, b: b, score: score, flag: flag }
51
+ matched += 1
52
+ end
53
+ end
54
+
55
+ flush_updates
56
+
57
+ consolidate_groups if matched > 0
58
+
59
+ elapsed = Time.now.utc - start_time
60
+
61
+ if @log_path
62
+ log_stats = {
63
+ scanned: activities.size,
64
+ compared: @matched_pairs.size,
65
+ matched: matched,
66
+ by_flag: @matched_pairs.group_by { |mp| mp[:flag] }.transform_values(&:size),
67
+ }
68
+ LogWriter.new(@log_path).write(
69
+ stats: log_stats,
70
+ matches: @matched_pairs.map.with_index do |mp, idx|
71
+ {
72
+ id: idx + 1,
73
+ a: { run_id: mp[:a][:run_id], source: mp[:a][:source] },
74
+ b: { run_id: mp[:b][:run_id], source: mp[:b][:source] },
75
+ confidence: mp[:score],
76
+ flag: mp[:flag],
77
+ }
78
+ end,
79
+ elapsed: elapsed,
80
+ )
81
+ end
82
+
83
+ puts "DedupService: #{matched} matches in #{elapsed.round(1)}s" if @verbose
84
+ matched
85
+ end
86
+
87
+ private
88
+
89
+ def activities_scope
90
+ base = @db[:activities].where(Sequel.lit('distance > 100')).order(:start_date)
91
+ return base unless @incremental
92
+ base.where(Sequel.lit('run_id NOT IN (SELECT run_id FROM dedup_group_members)'))
93
+ end
94
+
95
+ def parse_time(val)
96
+ return nil unless val
97
+ t = Time.parse(val.to_s)
98
+ t.utc? ? t : t.utc
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ def activity_range(activity)
104
+ duration = [activity[:elapsed_time].to_i, activity[:moving_time].to_i].max
105
+ duration = 3600 if duration <= 0
106
+ start_t = parse_time(activity[:start_date])
107
+ { start: start_t, end: start_t + duration }
108
+ end
109
+
110
+ def classify_flag(a, b, score)
111
+ if score >= 0.85
112
+ 'derived'
113
+ elsif score >= 0.15
114
+ traj_a = @trajectory.points(a[:run_id])
115
+ traj_b = @trajectory.points(b[:run_id])
116
+
117
+ if traj_a.nil? || traj_b.nil? || traj_a.size < 2 || traj_b.size < 2
118
+ 'similar'
119
+ else
120
+ containment = @trajectory.containment_relation(traj_a, traj_b)
121
+ case containment
122
+ when :equivalent then 'duplicate'
123
+ when :contained_a, :contained_b then 'contained'
124
+ when :partial then 'partial'
125
+ when :cross then 'cross'
126
+ else 'similar'
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def append_info(run_id, source, matched_run_id, score, flag)
133
+ return unless flag
134
+
135
+ entry = { 'source' => source, 'run_id' => matched_run_id,
136
+ 'confidence' => score, 'flag' => flag }
137
+ @updates[run_id] ||= []
138
+ existing = @updates[run_id].find { |e| e['run_id'] == matched_run_id }
139
+ if existing
140
+ existing.merge!(entry)
141
+ else
142
+ @updates[run_id] << entry
143
+ end
144
+ end
145
+
146
+ def flush_updates
147
+ @updates.each do |run_id, entries|
148
+ existing_json = @db[:activities].where(run_id: run_id).get(:duplicate_info)
149
+ existing = begin
150
+ existing_json ? JSON.parse(existing_json) : []
151
+ rescue JSON::ParserError
152
+ []
153
+ end
154
+ merged = merge_entries(existing, entries)
155
+ new_json = merged.empty? ? nil : JSON.generate(merged)
156
+ @db[:activities].where(run_id: run_id).update(duplicate_info: new_json)
157
+ end
158
+ end
159
+
160
+ def merge_entries(existing, new_entries)
161
+ by_run_id = {}
162
+ existing.each { |e| by_run_id[e['run_id']] = e }
163
+ new_entries.each { |e| by_run_id[e['run_id']] = e }
164
+ by_run_id.values
165
+ end
166
+
167
+ def consolidate_groups
168
+ return if @matched_pairs.empty?
169
+
170
+ consolidator = GroupConsolidator.new
171
+ groups = consolidator.consolidate(@matched_pairs)
172
+
173
+ groups.each do |group_id, run_ids|
174
+ members = @db[:activities].where(run_id: run_ids).all
175
+ write_dedup_group(group_id, members)
176
+ end
177
+ end
178
+
179
+ def write_dedup_group(group_id, members)
180
+ now = Time.now.utc
181
+ @db[:dedup_groups].insert_conflict(target: :group_id).insert(
182
+ group_id: group_id, created_at: now, updated_at: now,
183
+ )
184
+
185
+ best = Hash.new { |h, k| h[k] = { confidence: 0.0, flag: nil } }
186
+ @matched_pairs.each do |mp|
187
+ [mp[:a], mp[:b]].each do |act|
188
+ if mp[:score] > best[act[:run_id]][:confidence]
189
+ best[act[:run_id]] = { confidence: mp[:score], flag: mp[:flag] }
190
+ end
191
+ end
192
+ end
193
+
194
+ members.each do |m|
195
+ entry = best[m[:run_id]]
196
+ @db[:dedup_group_members].insert_conflict(target: [:group_id, :run_id]).insert(
197
+ group_id: group_id,
198
+ run_id: m[:run_id],
199
+ source: m[:source],
200
+ flag: entry[:flag],
201
+ confidence: entry[:confidence],
202
+ )
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module GitFit
6
+ module Dedup
7
+ class Trajectory
8
+ SAMPLE_RATE = 10 # take every Nth point
9
+ PROXIMITY_M = 50 # points within 50m count as same
10
+ DP_EPSILON = 0.0001 # ~10m in lat/lng degrees
11
+ CONTAINED_THRESHOLD = 0.7 # 70% overlap → equivalent/contained
12
+ PARTIAL_THRESHOLD = 0.3 # 30% → partial boundary
13
+ MAX_EXPECTED_COST = 1000.0 # DTW normalization denominator
14
+ PLACEHOLDER_POLYLINE = 'gqqrFurkeU??'
15
+
16
+ def initialize(db)
17
+ @db = db
18
+ @cache = {}
19
+ end
20
+
21
+ def duplicate?(run_id_a, run_id_b)
22
+ pts_a = cached_points(run_id_a)
23
+ pts_b = cached_points(run_id_b)
24
+ return nil unless pts_a && pts_b && pts_a.size >= 2 && pts_b.size >= 2
25
+
26
+ shorter = pts_a.size <= pts_b.size ? pts_a : pts_b
27
+ longer = pts_a.size > pts_b.size ? pts_a : pts_b
28
+
29
+ sampled = shorter.each_slice(SAMPLE_RATE).map(&:first)
30
+ matches = sampled.count { |pt| near(pt, longer) }
31
+ ratio = matches.to_f / sampled.size
32
+ ratio >= 0.3
33
+ end
34
+
35
+ def points(run_id)
36
+ cached_points(run_id)
37
+ end
38
+
39
+ def douglas_peucker(points, epsilon = DP_EPSILON)
40
+ return points if points.size <= 2
41
+
42
+ coords = points.map { |p| point_coordinates(p) }.compact
43
+ return points if coords.size <= 2
44
+
45
+ stack = [[0, coords.size - 1]]
46
+ keep = Set.new([0, coords.size - 1])
47
+
48
+ while stack.any?
49
+ start_idx, end_idx = stack.pop
50
+ dmax = 0.0
51
+ idx = start_idx
52
+
53
+ ((start_idx + 1)...end_idx).each do |i|
54
+ d = perpendicular_distance(coords[i], coords[start_idx], coords[end_idx])
55
+ if d > dmax
56
+ dmax = d
57
+ idx = i
58
+ end
59
+ end
60
+
61
+ next unless dmax > epsilon
62
+ keep.add(idx)
63
+ stack.push([start_idx, idx])
64
+ stack.push([idx, end_idx])
65
+ end
66
+
67
+ coords.select.with_index { |_, i| keep.include?(i) }
68
+ end
69
+
70
+ def dtw_similarity(traj_a, traj_b)
71
+ return nil if traj_a.nil? || traj_b.nil? || traj_a.size < 2 || traj_b.size < 2
72
+
73
+ a = traj_a.map { |p| point_coordinates(p) }.compact
74
+ b = traj_b.map { |p| point_coordinates(p) }.compact
75
+ return nil if a.size < 2 || b.size < 2
76
+
77
+ n = a.size
78
+ m = b.size
79
+ dtw = Array.new(n + 1) { Array.new(m + 1, Float::INFINITY) }
80
+ dtw[0][0] = 0
81
+
82
+ (1..n).each do |i|
83
+ (1..m).each do |j|
84
+ cost = haversine(a[i - 1][0], a[i - 1][1], b[j - 1][0], b[j - 1][1])
85
+ dtw[i][j] = cost + [dtw[i - 1][j], dtw[i][j - 1], dtw[i - 1][j - 1]].min
86
+ end
87
+ end
88
+
89
+ avg_cost = dtw[n][m] / [n, m].max
90
+ [1.0 - avg_cost / MAX_EXPECTED_COST, 0.0].max
91
+ end
92
+
93
+ def hausdorff_distance(traj_a, traj_b)
94
+ return nil if traj_a.nil? || traj_b.nil? || traj_a.size < 2 || traj_b.size < 2
95
+
96
+ a_coords = traj_a.map { |p| point_coordinates(p) }.compact
97
+ b_coords = traj_b.map { |p| point_coordinates(p) }.compact
98
+ return nil if a_coords.size < 2 || b_coords.size < 2
99
+
100
+ max_min_a = a_coords.map { |p| min_distance_to_set(p, b_coords) }.max || 0.0
101
+ max_min_b = b_coords.map { |p| min_distance_to_set(p, a_coords) }.max || 0.0
102
+ [max_min_a, max_min_b].max
103
+ end
104
+
105
+ def normalized_hausdorff_score(traj_a, traj_b)
106
+ dist = hausdorff_distance(traj_a, traj_b)
107
+ return nil if dist.nil?
108
+ [1.0 - dist / 2000.0, 0.0].max
109
+ end
110
+
111
+ def overlap_score(traj_a, traj_b, _radius = PROXIMITY_M)
112
+ return nil if traj_a.nil? || traj_b.nil? || traj_a.size < 2 || traj_b.size < 2
113
+
114
+ sampled_a = traj_a.each_slice(SAMPLE_RATE).map(&:first)
115
+ sampled_b = traj_b.each_slice(SAMPLE_RATE).map(&:first)
116
+ return nil if sampled_a.empty? || sampled_b.empty?
117
+
118
+ a_in_b = sampled_a.count { |p| near(p, sampled_b) }
119
+ b_in_a = sampled_b.count { |p| near(p, sampled_a) }
120
+
121
+ ratio_a = a_in_b.to_f / sampled_a.size
122
+ ratio_b = b_in_a.to_f / sampled_b.size
123
+ (ratio_a + ratio_b) / 2.0
124
+ end
125
+
126
+ def containment_relation(traj_a, traj_b, _radius = PROXIMITY_M, threshold = CONTAINED_THRESHOLD)
127
+ return nil if traj_a.nil? || traj_b.nil? || traj_a.size < 2 || traj_b.size < 2
128
+
129
+ sampled_a = traj_a.each_slice(SAMPLE_RATE).map(&:first)
130
+ sampled_b = traj_b.each_slice(SAMPLE_RATE).map(&:first)
131
+ return nil if sampled_a.empty? || sampled_b.empty?
132
+
133
+ a_in_b = sampled_a.count { |p| near(p, sampled_b) } / sampled_a.size.to_f
134
+ b_in_a = sampled_b.count { |p| near(p, sampled_a) } / sampled_b.size.to_f
135
+
136
+ if a_in_b >= threshold && b_in_a >= threshold
137
+ :equivalent
138
+ elsif a_in_b >= threshold
139
+ :contained_a
140
+ elsif b_in_a >= threshold
141
+ :contained_b
142
+ elsif a_in_b > PARTIAL_THRESHOLD || b_in_a > PARTIAL_THRESHOLD
143
+ :partial
144
+ elsif a_in_b > 0 || b_in_a > 0
145
+ :cross
146
+ else
147
+ :disjoint
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def cached_points(run_id)
154
+ @cache[run_id] ||= load_points(run_id)
155
+ end
156
+
157
+ def load_points(run_id)
158
+ points = load_std_points(run_id)
159
+ return points if points
160
+
161
+ polyline = load_db_polyline(run_id)
162
+ return nil unless polyline
163
+
164
+ coords = Geo::Polyline.decode(polyline)
165
+ return nil if coords.nil? || coords.empty?
166
+
167
+ coords.map { |lat, lng| { 'latitude' => lat, 'longitude' => lng } }
168
+ end
169
+
170
+ def load_std_points(run_id)
171
+ source = source_from_run_id(run_id)
172
+ return nil unless source
173
+
174
+ std_dir = File.join('data', 'std', source)
175
+ id = run_id.sub("#{source}_", '')
176
+ path = Dir.glob(File.join(std_dir, "#{id}.*")).first
177
+ return nil unless path && File.exist?(path)
178
+
179
+ data = JSON.parse(File.read(path))
180
+ return data if data.is_a?(Array) && data.first.is_a?(Hash)
181
+
182
+ nil
183
+ rescue Errno::ENOENT, JSON::ParserError
184
+ nil
185
+ end
186
+
187
+ def load_db_polyline(run_id)
188
+ polyline = @db[:activities].where(run_id: run_id).get(:summary_polyline)
189
+ return nil if polyline.nil? || polyline.empty?
190
+ return nil if polyline == PLACEHOLDER_POLYLINE
191
+ polyline
192
+ end
193
+
194
+ def source_from_run_id(run_id)
195
+ %w[garmin_cn garmin strava keep igpsport xoss xingzhe].each do |s|
196
+ return s if run_id.start_with?("#{s}_")
197
+ end
198
+ nil
199
+ end
200
+
201
+ def near(pt, candidates)
202
+ lat = pt['latitude'] || pt['positionLat'] || pt[:latitude] || pt[:positionLat]
203
+ lng = pt['longitude'] || pt['positionLong'] || pt[:longitude] || pt[:positionLong]
204
+ return false unless lat && lng
205
+
206
+ candidates.any? do |c|
207
+ clat = c['latitude'] || c['positionLat'] || c[:latitude] || c[:positionLat]
208
+ clng = c['longitude'] || c['positionLong'] || c[:longitude] || c[:positionLong]
209
+ next false unless clat && clng
210
+
211
+ haversine(lat, lng, clat, clng) <= PROXIMITY_M
212
+ end
213
+ end
214
+
215
+ def haversine(lat1, lon1, lat2, lon2)
216
+ r = 6_371_000
217
+ dlat = (lat2 - lat1) * Math::PI / 180
218
+ dlon = (lon2 - lon1) * Math::PI / 180
219
+ a = Math.sin(dlat / 2)**2 +
220
+ Math.cos(lat1 * Math::PI / 180) *
221
+ Math.cos(lat2 * Math::PI / 180) *
222
+ Math.sin(dlon / 2)**2
223
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
224
+ r * c
225
+ end
226
+
227
+ def point_coordinates(pt)
228
+ lat = pt['latitude'] || pt['positionLat'] || pt[:latitude] || pt[:positionLat]
229
+ lng = pt['longitude'] || pt['positionLong'] || pt[:longitude] || pt[:positionLong]
230
+ [lat, lng] if lat && lng
231
+ end
232
+
233
+ def perpendicular_distance(pt, a, b)
234
+ lat1 = a[0] * Math::PI / 180
235
+ lon1 = a[1] * Math::PI / 180
236
+ lat2 = b[0] * Math::PI / 180
237
+ lon2 = b[1] * Math::PI / 180
238
+ lat3 = pt[0] * Math::PI / 180
239
+ lon3 = pt[1] * Math::PI / 180
240
+
241
+ d13 = Math.acos(Math.sin(lat1) * Math.sin(lat3) + Math.cos(lat1) * Math.cos(lat3) * Math.cos(lon3 - lon1))
242
+ theta12 = Math.atan2(Math.sin(lon2 - lon1) * Math.cos(lat2),
243
+ Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1))
244
+ theta13 = Math.atan2(Math.sin(lon3 - lon1) * Math.cos(lat3),
245
+ Math.cos(lat1) * Math.sin(lat3) - Math.sin(lat1) * Math.cos(lat3) * Math.cos(lon3 - lon1))
246
+ cross = Math.asin(Math.sin(d13) * Math.sin(theta13 - theta12))
247
+ (cross.abs * 6371000.0)
248
+ end
249
+
250
+ def min_distance_to_set(pt, candidates)
251
+ candidates.map { |c| haversine(pt[0], pt[1], c[0], c[1]) }.min
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module GitFit
8
+ module Export
9
+ class GPX
10
+ CATEGORY_MAP = {
11
+ 'run' => 'running', 'ride' => 'cycling', 'walk' => 'walking',
12
+ 'hike' => 'hiking', 'swim' => 'swimming', 'ski' => 'skiing',
13
+ 'workout' => 'other', 'other' => 'other'
14
+ }.freeze
15
+
16
+ def initialize(db:, output:, activity_filter: nil)
17
+ @db = db
18
+ @output = output
19
+ @activity_filter = activity_filter
20
+ end
21
+
22
+ def call
23
+ FileUtils.mkdir_p(@output)
24
+ activities = load_activities
25
+ return 0 if activities.empty?
26
+
27
+ activities.each { |a| write_gpx(a) }
28
+ activities.size
29
+ end
30
+
31
+ private
32
+
33
+ def load_activities
34
+ ds = @db[:activities]
35
+ .where(Sequel.lit('summary_polyline IS NOT NULL AND summary_polyline != ?', ''))
36
+ .order(Sequel.desc(:start_date))
37
+ ds = ds.where(sport_category: @activity_filter) if @activity_filter
38
+ ds.all
39
+ end
40
+
41
+ def write_gpx(activity)
42
+ points = l2_points(activity) || polyline_points(activity)
43
+ return unless points && points.size >= 2
44
+
45
+ safe_id = activity[:run_id].to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
46
+ name = esc(activity[:name] || 'Activity')
47
+ type = CATEGORY_MAP[activity[:sport_category]] || 'other'
48
+ start_t = parse_time(activity[:start_date])
49
+
50
+ xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
51
+ xml << %(<gpx version="1.1" creator="GitFit"\n)
52
+ xml << %( xmlns="http://www.topografix.com/GPX/1/1"\n)
53
+ xml << %( xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">\n)
54
+ xml << %(<metadata>\n <name>#{name}</name>\n <time>#{start_t}</time>\n</metadata>\n)
55
+ xml << %(<trk>\n <name>#{name}</name>\n <type>#{type}</type>\n <trkseg>\n)
56
+
57
+ points.each do |pt|
58
+ s = +%( <trkpt lat="#{fmt(pt[:lat])}" lon="#{fmt(pt[:lon])}">\n)
59
+ s << %( <ele>#{fmt(pt[:ele])}</ele>\n) if pt[:ele]
60
+ s << %( <time>#{pt[:time]}</time>\n) if pt[:time]
61
+ if pt[:hr] || pt[:cad] || pt[:watts] || pt[:temp]
62
+ s << %( <extensions>\n)
63
+ s << %( <gpxtpx:TrackPointExtension>\n)
64
+ s << %( <gpxtpx:hr>#{pt[:hr].to_i}</gpxtpx:hr>\n) if pt[:hr]
65
+ s << %( <gpxtpx:cad>#{pt[:cad].to_i}</gpxtpx:cad>\n) if pt[:cad]
66
+ s << %( <gpxtpx:watts>#{pt[:watts].to_i}</gpxtpx:watts>\n) if pt[:watts]
67
+ s << %( <gpxtpx:temp>#{pt[:temp].to_i}</gpxtpx:temp>\n) if pt[:temp]
68
+ s << %( </gpxtpx:TrackPointExtension>\n)
69
+ s << %( </extensions>\n)
70
+ end
71
+ s << %( </trkpt>\n)
72
+ xml << s
73
+ end
74
+
75
+ xml << %(</trkseg>\n</trk>\n</gpx>\n)
76
+ File.write(File.join(@output, "#{safe_id}.gpx"), xml)
77
+ end
78
+
79
+ def l2_points(activity)
80
+ return nil unless activity[:source]
81
+
82
+ path = File.join('data', 'std', activity[:source].to_s, "#{activity[:run_id]}.json")
83
+ return nil unless File.exist?(path)
84
+
85
+ records = ::JSON.parse(File.read(path))
86
+ return nil if records.empty?
87
+
88
+ pts = []
89
+ records.each do |r|
90
+ lat = r['positionLat'] || r['latitude']
91
+ lng = r['positionLong'] || r['longitude']
92
+ next unless lat && lng
93
+
94
+ ele = r['altitude']
95
+ time = parse_time(r['timestamp'])
96
+ hr = r['heartRate'] || r['heartrate'] || r['heart_rate']
97
+ pts << {
98
+ lat: lat, lon: lng, ele: ele, time: time,
99
+ hr: hr, cad: r['cadence'], watts: r['power'] || r['watts'],
100
+ temp: r['temperature']
101
+ }
102
+ end
103
+ pts.size >= 2 ? pts : nil
104
+ end
105
+
106
+ def polyline_points(activity)
107
+ pts = Geo::Polyline.decode(activity[:summary_polyline])
108
+ return nil if pts.size < 2
109
+ pts.map { |lat, lng| { lat: lat, lon: lng } }
110
+ end
111
+
112
+ def parse_time(value)
113
+ return nil unless value
114
+ Time.parse(value.to_s).utc.xmlschema
115
+ rescue ArgumentError, TypeError
116
+ nil
117
+ end
118
+
119
+ def fmt(value)
120
+ case value
121
+ when Float then value.round(6).to_s
122
+ when nil then ''
123
+ else value.to_s
124
+ end
125
+ end
126
+
127
+ def esc(str)
128
+ str.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
129
+ .gsub("'", '&apos;').gsub('"', '&quot;')
130
+ end
131
+ end
132
+ end
133
+ end