git-fit 0.10.4 → 0.10.6
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/lib/git-fit.rb +9 -0
- data/lib/git_fit/auth/garmin_token.rb +32 -0
- data/lib/git_fit/auth/strava.rb +223 -0
- data/lib/git_fit/cli/export.rb +20 -1
- data/lib/git_fit/cli/sync.rb +54 -0
- data/lib/git_fit/cli.rb +29 -0
- data/lib/git_fit/config_template.rb +9 -0
- data/lib/git_fit/dedup/field_elector.rb +81 -0
- data/lib/git_fit/dedup/group_consolidator.rb +58 -0
- data/lib/git_fit/dedup/log_writer.rb +33 -0
- data/lib/git_fit/dedup/matcher.rb +90 -0
- data/lib/git_fit/dedup/service.rb +207 -0
- data/lib/git_fit/dedup/trajectory.rb +243 -0
- data/lib/git_fit/export/gpx.rb +133 -0
- data/lib/git_fit/export/json.rb +31 -8
- data/lib/git_fit/export.rb +1 -0
- data/lib/git_fit/fit.rb +6 -0
- data/lib/git_fit/import/local_file.rb +3 -2
- data/lib/git_fit/std/resolver.rb +32 -0
- data/lib/git_fit/sync/base.rb +11 -0
- data/lib/git_fit/sync/igpsport.rb +1 -1
- data/lib/git_fit/sync/strava.rb +31 -18
- data/lib/git_fit/sync/xoss.rb +1 -1
- data/lib/git_fit/version.rb +1 -1
- metadata +25 -1
|
@@ -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,243 @@
|
|
|
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
|
+
path = GitFit::Std::Resolver.find(run_id)
|
|
172
|
+
return nil unless path
|
|
173
|
+
|
|
174
|
+
data = JSON.parse(File.read(path))
|
|
175
|
+
return data if data.is_a?(Array) && data.first.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
nil
|
|
178
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def load_db_polyline(run_id)
|
|
183
|
+
polyline = @db[:activities].where(run_id: run_id).get(:summary_polyline)
|
|
184
|
+
return nil if polyline.nil? || polyline.empty?
|
|
185
|
+
return nil if polyline == PLACEHOLDER_POLYLINE
|
|
186
|
+
polyline
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def near(pt, candidates)
|
|
190
|
+
lat = pt['latitude'] || pt['positionLat'] || pt[:latitude] || pt[:positionLat]
|
|
191
|
+
lng = pt['longitude'] || pt['positionLong'] || pt[:longitude] || pt[:positionLong]
|
|
192
|
+
return false unless lat && lng
|
|
193
|
+
|
|
194
|
+
candidates.any? do |c|
|
|
195
|
+
clat = c['latitude'] || c['positionLat'] || c[:latitude] || c[:positionLat]
|
|
196
|
+
clng = c['longitude'] || c['positionLong'] || c[:longitude] || c[:positionLong]
|
|
197
|
+
next false unless clat && clng
|
|
198
|
+
|
|
199
|
+
haversine(lat, lng, clat, clng) <= PROXIMITY_M
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def haversine(lat1, lon1, lat2, lon2)
|
|
204
|
+
r = 6_371_000
|
|
205
|
+
dlat = (lat2 - lat1) * Math::PI / 180
|
|
206
|
+
dlon = (lon2 - lon1) * Math::PI / 180
|
|
207
|
+
a = Math.sin(dlat / 2)**2 +
|
|
208
|
+
Math.cos(lat1 * Math::PI / 180) *
|
|
209
|
+
Math.cos(lat2 * Math::PI / 180) *
|
|
210
|
+
Math.sin(dlon / 2)**2
|
|
211
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
212
|
+
r * c
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def point_coordinates(pt)
|
|
216
|
+
lat = pt['latitude'] || pt['positionLat'] || pt[:latitude] || pt[:positionLat]
|
|
217
|
+
lng = pt['longitude'] || pt['positionLong'] || pt[:longitude] || pt[:positionLong]
|
|
218
|
+
[lat, lng] if lat && lng
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def perpendicular_distance(pt, a, b)
|
|
222
|
+
lat1 = a[0] * Math::PI / 180
|
|
223
|
+
lon1 = a[1] * Math::PI / 180
|
|
224
|
+
lat2 = b[0] * Math::PI / 180
|
|
225
|
+
lon2 = b[1] * Math::PI / 180
|
|
226
|
+
lat3 = pt[0] * Math::PI / 180
|
|
227
|
+
lon3 = pt[1] * Math::PI / 180
|
|
228
|
+
|
|
229
|
+
d13 = Math.acos(Math.sin(lat1) * Math.sin(lat3) + Math.cos(lat1) * Math.cos(lat3) * Math.cos(lon3 - lon1))
|
|
230
|
+
theta12 = Math.atan2(Math.sin(lon2 - lon1) * Math.cos(lat2),
|
|
231
|
+
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1))
|
|
232
|
+
theta13 = Math.atan2(Math.sin(lon3 - lon1) * Math.cos(lat3),
|
|
233
|
+
Math.cos(lat1) * Math.sin(lat3) - Math.sin(lat1) * Math.cos(lat3) * Math.cos(lon3 - lon1))
|
|
234
|
+
cross = Math.asin(Math.sin(d13) * Math.sin(theta13 - theta12))
|
|
235
|
+
(cross.abs * 6371000.0)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def min_distance_to_set(pt, candidates)
|
|
239
|
+
candidates.map { |c| haversine(pt[0], pt[1], c[0], c[1]) }.min
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
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 = GitFit::Std::Resolver.find(activity[:run_id].to_s)
|
|
83
|
+
return nil unless 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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
129
|
+
.gsub("'", ''').gsub('"', '"')
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|