git-fit 0.6.1 → 0.7.1
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 +10 -3
- data/lib/git_fit/cli/sync.rb +23 -34
- data/lib/git_fit/config.rb +9 -2
- data/lib/git_fit/config_template.rb +12 -0
- data/lib/git_fit/sync/base.rb +64 -1
- data/lib/git_fit/sync/garmin.rb +16 -0
- data/lib/git_fit/sync/garmin_base.rb +648 -0
- data/lib/git_fit/sync/garmin_base_di.rb +186 -0
- data/lib/git_fit/sync/garmin_cn.rb +29 -0
- data/lib/git_fit/sync/igpsport.rb +225 -0
- data/lib/git_fit/sync/keep.rb +499 -0
- data/lib/git_fit/sync/lorem.rb +1 -0
- data/lib/git_fit/sync/runner.rb +322 -0
- data/lib/git_fit/sync/strava.rb +187 -0
- data/lib/git_fit/sync/xingzhe.rb +262 -0
- data/lib/git_fit/sync/xoss.rb +369 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +66 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "time"
|
|
6
|
+
require "tzinfo"
|
|
7
|
+
|
|
8
|
+
module GitFit
|
|
9
|
+
module Sync
|
|
10
|
+
class Keep < Base
|
|
11
|
+
LOGIN_URL = "https://api.gotokeep.com/v1.1/users/login"
|
|
12
|
+
STATS_URL = "https://api.gotokeep.com/pd/v3/stats/detail?dateUnit=all&type=%{sport}&lastDate=%{last_date}"
|
|
13
|
+
LOG_URL = "https://api.gotokeep.com/pd/v3/%{sport}log/%{run_id}"
|
|
14
|
+
|
|
15
|
+
AES_KEY = Base64.decode64("NTZmZTU5OzgyZzpkODczYw==")
|
|
16
|
+
AES_IV = Base64.decode64("MjM0Njg5MjQzMjkyMDMwMA==")
|
|
17
|
+
|
|
18
|
+
PLACEHOLDER_POLYLINE = "gqqrFurkeU??".freeze
|
|
19
|
+
|
|
20
|
+
KEEP_TYPES = %w[running hiking cycling].freeze
|
|
21
|
+
KEEP_RAW_EXT = "json".freeze
|
|
22
|
+
|
|
23
|
+
TYPE_MAP = {
|
|
24
|
+
"outdoorWalking" => "walk",
|
|
25
|
+
"outdoorRunning" => "run",
|
|
26
|
+
"outdoorCycling" => "ride",
|
|
27
|
+
"indoorRunning" => "run",
|
|
28
|
+
"mountaineering" => "hike"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
STRAVA_TYPE_MAP = {
|
|
32
|
+
"outdoorWalking" => "Walk",
|
|
33
|
+
"outdoorRunning" => "Run",
|
|
34
|
+
"outdoorCycling" => "Ride",
|
|
35
|
+
"indoorRunning" => "VirtualRun",
|
|
36
|
+
"mountaineering" => "Hiking"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
TIMESTAMP_THRESHOLD = 3_600_000
|
|
40
|
+
HR_THRESHOLD = 100
|
|
41
|
+
|
|
42
|
+
register_adapter
|
|
43
|
+
register_config :phone, :password
|
|
44
|
+
|
|
45
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
46
|
+
super
|
|
47
|
+
@phone = @config["phone"] || @config["mobile"]
|
|
48
|
+
@password = @config["password"]
|
|
49
|
+
@headers = {
|
|
50
|
+
"User-Agent" => "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0",
|
|
51
|
+
"Content-Type" => "application/x-www-form-urlencoded;charset=utf-8"
|
|
52
|
+
}
|
|
53
|
+
@session = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def before_call
|
|
57
|
+
return false if @phone.nil? || @password.nil?
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def authenticate
|
|
62
|
+
@session = login
|
|
63
|
+
@session ? true : false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pending_ids
|
|
67
|
+
existing = existing_run_ids
|
|
68
|
+
ids = []
|
|
69
|
+
KEEP_TYPES.each do |sport|
|
|
70
|
+
compound_ids = fetch_run_ids(sport)
|
|
71
|
+
compound_ids.each do |cid|
|
|
72
|
+
keep_id = extract_id(cid) || cid
|
|
73
|
+
run_id = "keep_#{keep_id}"
|
|
74
|
+
has_existing = existing.include?(run_id)
|
|
75
|
+
has_raw = raw_file_exists?(keep_id)
|
|
76
|
+
next if has_existing && has_raw
|
|
77
|
+
ids << { id: cid, keep_id: keep_id, sport: sport, has_raw: has_raw }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
ids
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def process_one(pending)
|
|
84
|
+
if pending[:has_raw]
|
|
85
|
+
upsert_from_raw(pending[:id])
|
|
86
|
+
else
|
|
87
|
+
sync_single_run_with_raw(pending[:id], pending[:sport])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def http_post(url, body, headers)
|
|
94
|
+
uri = URI(url)
|
|
95
|
+
req = Net::HTTP::Post.new(uri)
|
|
96
|
+
headers.each { |k, v| req[k] = v }
|
|
97
|
+
req.body = body.is_a?(Hash) ? URI.encode_www_form(body) : body
|
|
98
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
|
+
http.use_ssl = uri.scheme == "https"
|
|
100
|
+
http.open_timeout = 30
|
|
101
|
+
http.read_timeout = 60
|
|
102
|
+
http.start { |h| h.request(req) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def http_get(url, headers)
|
|
106
|
+
uri = URI(url)
|
|
107
|
+
req = Net::HTTP::Get.new(uri)
|
|
108
|
+
headers.each { |k, v| req[k] = v }
|
|
109
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
110
|
+
http.use_ssl = uri.scheme == "https"
|
|
111
|
+
http.open_timeout = 30
|
|
112
|
+
http.read_timeout = 60
|
|
113
|
+
http.start { |h| h.request(req) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def login
|
|
117
|
+
resp = http_post(LOGIN_URL, { mobile: @phone, password: @password }, @headers)
|
|
118
|
+
return nil unless resp.code.to_i == 200
|
|
119
|
+
|
|
120
|
+
data = JSON.parse(resp.body)
|
|
121
|
+
token = data.dig("data", "token")
|
|
122
|
+
return nil unless token
|
|
123
|
+
|
|
124
|
+
@headers["Authorization"] = "Bearer #{token}"
|
|
125
|
+
@session = true
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sync_sport_type(sport)
|
|
131
|
+
pending_ids.select { |p| p[:sport] == sport }
|
|
132
|
+
.filter_map { |p| process_one(p) }
|
|
133
|
+
.size
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def existing_run_ids
|
|
137
|
+
@db[:activities].where(source: "keep").select_map(:run_id).map(&:to_s)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fetch_run_ids(sport)
|
|
141
|
+
ids = []
|
|
142
|
+
last_date = 0
|
|
143
|
+
|
|
144
|
+
loop do
|
|
145
|
+
url = STATS_URL % { sport: sport, last_date: last_date }
|
|
146
|
+
resp = @session ? http_get(url, @headers) : nil
|
|
147
|
+
break unless resp&.code&.to_i == 200
|
|
148
|
+
|
|
149
|
+
data = JSON.parse(resp.body)["data"]
|
|
150
|
+
break unless data
|
|
151
|
+
|
|
152
|
+
records = data["records"] || []
|
|
153
|
+
records.each do |r|
|
|
154
|
+
logs = r["logs"] || []
|
|
155
|
+
logs.each do |log|
|
|
156
|
+
stats = log["stats"] || log
|
|
157
|
+
ids << stats["id"] unless stats["isDoubtful"]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
last_date = data["lastTimestamp"]
|
|
162
|
+
break if last_date.nil? || last_date.zero?
|
|
163
|
+
|
|
164
|
+
spider_sleep
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
ids
|
|
168
|
+
rescue JSON::ParserError
|
|
169
|
+
ids
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def sync_single_run_with_raw(compound_id, sport)
|
|
173
|
+
url = LOG_URL % { sport: sport, run_id: compound_id }
|
|
174
|
+
resp = http_get(url, @headers)
|
|
175
|
+
return false unless resp.code.to_i == 200
|
|
176
|
+
|
|
177
|
+
data = JSON.parse(resp.body)
|
|
178
|
+
run_data = data["data"]
|
|
179
|
+
return false unless run_data
|
|
180
|
+
|
|
181
|
+
payload = decrypt_raw_payload(run_data)
|
|
182
|
+
keep_id = extract_id(run_data["id"])
|
|
183
|
+
return false unless keep_id
|
|
184
|
+
|
|
185
|
+
write_source_archive({
|
|
186
|
+
source: "keep",
|
|
187
|
+
decodedAt: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
188
|
+
meta: {
|
|
189
|
+
startTime: run_data["startTime"],
|
|
190
|
+
endTime: run_data["endTime"],
|
|
191
|
+
duration: run_data["duration"],
|
|
192
|
+
dataType: run_data["dataType"],
|
|
193
|
+
distance: run_data["distance"],
|
|
194
|
+
region: run_data["region"]
|
|
195
|
+
},
|
|
196
|
+
geoPoints: payload[:geo_points],
|
|
197
|
+
heartRates: payload[:hr_data]
|
|
198
|
+
}, keep_id)
|
|
199
|
+
|
|
200
|
+
wgs_points = compute_wgs_points(payload, run_data)
|
|
201
|
+
write_standardized_json(wgs_points, payload, run_data, keep_id)
|
|
202
|
+
|
|
203
|
+
attrs = build_activity_from_wgs(run_data, wgs_points)
|
|
204
|
+
return false unless attrs
|
|
205
|
+
|
|
206
|
+
upsert_activity(attrs)
|
|
207
|
+
attrs
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def upsert_from_raw(compound_id)
|
|
213
|
+
keep_id = extract_id(compound_id) || compound_id
|
|
214
|
+
raw = JSON.parse(File.read(raw_path(keep_id)))
|
|
215
|
+
run_data = raw["meta"].merge("id" => compound_id)
|
|
216
|
+
|
|
217
|
+
wgs = load_wgs_points(keep_id, raw)
|
|
218
|
+
return false unless wgs&.any?
|
|
219
|
+
|
|
220
|
+
if raw["heartRates"]&.any?
|
|
221
|
+
vals = raw["heartRates"].map { |h| h["value"] }.compact
|
|
222
|
+
run_data["heartRate"] = { "averageHeartRate" => vals.sum / vals.size } if vals.any?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
attrs = build_activity_from_wgs(run_data, wgs)
|
|
226
|
+
return false unless attrs
|
|
227
|
+
|
|
228
|
+
upsert_activity(attrs)
|
|
229
|
+
attrs
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def load_wgs_points(keep_id, raw)
|
|
233
|
+
std_path = File.join(std_dir, "#{keep_id}.json")
|
|
234
|
+
if File.exist?(std_path)
|
|
235
|
+
pts = JSON.parse(File.read(std_path))
|
|
236
|
+
pts.map { |pt| [pt["latitude"], pt["longitude"], pt["altitude"],
|
|
237
|
+
pt["timestamp"] ? Time.parse(pt["timestamp"]) : nil, pt["heartrate"]] }
|
|
238
|
+
else
|
|
239
|
+
payload = { geo_points: raw["geoPoints"], hr_data: raw["heartRates"] }
|
|
240
|
+
run_data = raw["meta"]
|
|
241
|
+
compute_wgs_points(payload, run_data)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def decrypt_raw_payload(run_data)
|
|
246
|
+
{
|
|
247
|
+
geo_points: decode_geo_points(run_data["geoPoints"]),
|
|
248
|
+
hr_data: decode_hr_data(run_data.dig("heartRate", "heartRates"))
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def compute_wgs_points(payload, run_data)
|
|
253
|
+
geo_points = payload[:geo_points]
|
|
254
|
+
hr_data = payload[:hr_data]
|
|
255
|
+
return [] unless geo_points&.any?
|
|
256
|
+
|
|
257
|
+
start_time = run_data["startTime"]
|
|
258
|
+
use_absolute_ts = geo_points.first["timestamp"].to_i > TIMESTAMP_THRESHOLD
|
|
259
|
+
effective_start = use_absolute_ts ? 0 : start_time
|
|
260
|
+
|
|
261
|
+
geo_points.map do |p|
|
|
262
|
+
lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(p["latitude"], p["longitude"])
|
|
263
|
+
ts = p["timestamp"] || p["unixTimestamp"]
|
|
264
|
+
pt_time = ts ? Time.at(effective_start / 1000.0 + ts.to_i / 10.0).utc : nil
|
|
265
|
+
hr = find_nearest_hr(hr_data, ts, start_time)
|
|
266
|
+
[lat, lng, p["altitude"], pt_time, hr]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def tz_resolver
|
|
271
|
+
@tz_resolver ||= GitFit::Timezone::Resolver.new({})
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def local_time_from_gps(start_utc, wgs_points)
|
|
275
|
+
return start_utc.iso8601 unless wgs_points&.any?
|
|
276
|
+
|
|
277
|
+
lat, lng = wgs_points.first[0..1]
|
|
278
|
+
tz_string = tz_resolver.resolve(lat, lng)
|
|
279
|
+
tz = TZInfo::Timezone.get(tz_string)
|
|
280
|
+
tz.utc_to_local(start_utc).iso8601
|
|
281
|
+
rescue => e
|
|
282
|
+
warn "Keep: timezone resolution failed: #{e.message}"
|
|
283
|
+
start_utc.iso8601
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def build_activity_from_wgs(run_data, wgs_points)
|
|
287
|
+
keep_id = extract_id(run_data["id"])
|
|
288
|
+
return nil unless keep_id
|
|
289
|
+
|
|
290
|
+
start_time = run_data["startTime"]
|
|
291
|
+
end_time = run_data["endTime"]
|
|
292
|
+
duration = run_data["duration"]
|
|
293
|
+
return nil unless duration
|
|
294
|
+
|
|
295
|
+
start_date = Time.at(start_time / 1000.0).utc
|
|
296
|
+
data_type = run_data["dataType"]
|
|
297
|
+
|
|
298
|
+
avg_hr = run_data.dig("heartRate", "averageHeartRate")
|
|
299
|
+
avg_hr = nil if avg_hr && avg_hr < 0
|
|
300
|
+
|
|
301
|
+
polyline_str = nil
|
|
302
|
+
total_elevation = run_data["elevation"]
|
|
303
|
+
computed_distance = run_data["distance"]
|
|
304
|
+
|
|
305
|
+
if wgs_points.any?
|
|
306
|
+
if wgs_points.any? { |pt| pt[3] }
|
|
307
|
+
computed_distance ||= total_distance_haversine(wgs_points)
|
|
308
|
+
total_elevation ||= elevation_gain_from_points(wgs_points)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
polyline_str = Geo::Polyline.encode(wgs_points.map { |pt| [pt[0], pt[1]] })
|
|
312
|
+
avg_hr ||= compute_avg_hr(wgs_points)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
moving_time = duration
|
|
316
|
+
elapsed_time = ((end_time - start_time) / 1000.0).round
|
|
317
|
+
|
|
318
|
+
{
|
|
319
|
+
run_id: "keep_#{keep_id}",
|
|
320
|
+
name: "#{STRAVA_TYPE_MAP[data_type] || data_type} from keep",
|
|
321
|
+
distance: computed_distance&.round(2),
|
|
322
|
+
moving_time: moving_time,
|
|
323
|
+
elapsed_time: elapsed_time,
|
|
324
|
+
sport_category: TYPE_MAP[data_type] || "other",
|
|
325
|
+
sport_type: data_type,
|
|
326
|
+
start_date: start_date.iso8601,
|
|
327
|
+
start_date_local: local_time_from_gps(start_date, wgs_points),
|
|
328
|
+
location_country: run_data["region"],
|
|
329
|
+
summary_polyline: polyline_str,
|
|
330
|
+
average_heartrate: avg_hr&.round(1),
|
|
331
|
+
average_speed: computed_distance && moving_time&.positive? ? (computed_distance.to_f / moving_time).round(2) : nil,
|
|
332
|
+
elevation_gain: total_elevation&.round(1),
|
|
333
|
+
source: "keep"
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def write_standardized_json(wgs_points, payload, run_data, keep_id)
|
|
338
|
+
geo_points = payload[:geo_points] || []
|
|
339
|
+
hr_data = payload[:hr_data]
|
|
340
|
+
start_time = run_data["startTime"]
|
|
341
|
+
|
|
342
|
+
extended = wgs_points.each_with_index.map do |pt, i|
|
|
343
|
+
orig = geo_points[i] || {}
|
|
344
|
+
ts = pt[3]
|
|
345
|
+
{
|
|
346
|
+
latitude: pt[0], longitude: pt[1], altitude: pt[2],
|
|
347
|
+
timestamp: ts&.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
348
|
+
heartrate: pt[4],
|
|
349
|
+
speed: orig["speed"],
|
|
350
|
+
currentTotalDistance: orig["currentTotalDistance"],
|
|
351
|
+
currentTotalDuration: orig["currentTotalDuration"],
|
|
352
|
+
currentPace: orig["currentPace"],
|
|
353
|
+
pressure: orig["pressure"],
|
|
354
|
+
accuracyRadius: orig["accuracyRadius"],
|
|
355
|
+
verticalAccuracy: orig["verticalAccuracy"],
|
|
356
|
+
locationType: orig["locationType"],
|
|
357
|
+
isPause: orig["isPause"],
|
|
358
|
+
flags: orig["flags"],
|
|
359
|
+
processLabel: orig["processLabel"],
|
|
360
|
+
crossKmMark: orig["crossKmMark"],
|
|
361
|
+
currentTotalSteps: orig["currentTotalSteps"]
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
dir = std_dir
|
|
366
|
+
FileUtils.mkdir_p(dir)
|
|
367
|
+
tmp = File.join(dir, ".#{keep_id}.json.tmp")
|
|
368
|
+
final = File.join(dir, "#{keep_id}.json")
|
|
369
|
+
File.write(tmp, JSON.generate(extended))
|
|
370
|
+
File.rename(tmp, final)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def raw_path(platform_id)
|
|
374
|
+
File.join(raw_dir, "#{platform_id}.#{KEEP_RAW_EXT}")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def extract_id(id_str)
|
|
378
|
+
return nil unless id_str
|
|
379
|
+
id_str.split("_")[1]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def decode_geo_points(geo_data)
|
|
383
|
+
return nil unless geo_data
|
|
384
|
+
raw = Base64.decode64(geo_data)
|
|
385
|
+
decrypted = aes_decrypt(raw)
|
|
386
|
+
decompressed = zlib_inflate(decrypted)
|
|
387
|
+
JSON.parse(decompressed)
|
|
388
|
+
rescue StandardError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def decode_hr_data(hr_data)
|
|
393
|
+
return nil unless hr_data
|
|
394
|
+
raw = Base64.decode64(hr_data)
|
|
395
|
+
decompressed = zlib_inflate(raw)
|
|
396
|
+
JSON.parse(decompressed)
|
|
397
|
+
rescue StandardError
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def aes_decrypt(data)
|
|
402
|
+
cipher = OpenSSL::Cipher.new("AES-128-CBC")
|
|
403
|
+
cipher.decrypt
|
|
404
|
+
cipher.key = AES_KEY
|
|
405
|
+
cipher.iv = AES_IV
|
|
406
|
+
cipher.update(data) + cipher.final
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def zlib_inflate(data)
|
|
410
|
+
[-Zlib::MAX_WBITS, Zlib::MAX_WBITS, Zlib::MAX_WBITS + 16].each do |wbits|
|
|
411
|
+
begin
|
|
412
|
+
inflater = Zlib::Inflate.new(wbits)
|
|
413
|
+
result = inflater.inflate(data)
|
|
414
|
+
inflater.finish rescue nil
|
|
415
|
+
inflater.close
|
|
416
|
+
return result
|
|
417
|
+
rescue Zlib::DataError
|
|
418
|
+
next
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
raise Zlib::DataError, "cannot inflate data"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def find_nearest_hr(hr_data, geo_ts, start_time, threshold: HR_THRESHOLD)
|
|
425
|
+
return nil unless hr_data&.any? && geo_ts
|
|
426
|
+
|
|
427
|
+
normalize = ->(ts) {
|
|
428
|
+
if ts > TIMESTAMP_THRESHOLD
|
|
429
|
+
ts - start_time / 100
|
|
430
|
+
else
|
|
431
|
+
ts
|
|
432
|
+
end
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
target = normalize.call(geo_ts)
|
|
436
|
+
|
|
437
|
+
nearest = nil
|
|
438
|
+
min_diff = Float::INFINITY
|
|
439
|
+
|
|
440
|
+
hr_data.each do |item|
|
|
441
|
+
ts = item["timestamp"]
|
|
442
|
+
next unless ts
|
|
443
|
+
|
|
444
|
+
diff = (normalize.call(ts) - target).abs
|
|
445
|
+
if diff <= threshold && diff < min_diff
|
|
446
|
+
nearest = item
|
|
447
|
+
min_diff = diff
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
hr = nearest&.dig("beatsPerMinute")
|
|
452
|
+
hr&.positive? ? hr : nil
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def compute_avg_hr(points)
|
|
456
|
+
hrs = points.map { |p| p[4] }.compact
|
|
457
|
+
return nil if hrs.empty?
|
|
458
|
+
hrs.sum / hrs.size
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def total_distance_haversine(points)
|
|
462
|
+
return 0.0 if points.length < 2
|
|
463
|
+
total = 0.0
|
|
464
|
+
(1...points.length).each do |i|
|
|
465
|
+
total += haversine_dist(points[i - 1], points[i])
|
|
466
|
+
end
|
|
467
|
+
total
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def haversine_dist(p1, p2)
|
|
471
|
+
lat1 = p1[0] * Math::PI / 180.0
|
|
472
|
+
lat2 = p2[0] * Math::PI / 180.0
|
|
473
|
+
dlat = (p2[0] - p1[0]) * Math::PI / 180.0
|
|
474
|
+
dlon = (p2[1] - p1[1]) * Math::PI / 180.0
|
|
475
|
+
|
|
476
|
+
a = Math.sin(dlat / 2) ** 2 +
|
|
477
|
+
Math.cos(lat1) * Math.cos(lat2) *
|
|
478
|
+
Math.sin(dlon / 2) ** 2
|
|
479
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
480
|
+
6371000.0 * c
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def elevation_gain_from_points(points)
|
|
484
|
+
total = 0.0
|
|
485
|
+
(1...points.length).each do |i|
|
|
486
|
+
elev1 = points[i - 1][2] || 0.0
|
|
487
|
+
elev2 = points[i][2] || 0.0
|
|
488
|
+
gain = elev2 - elev1
|
|
489
|
+
total += gain if gain > 0
|
|
490
|
+
end
|
|
491
|
+
total
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def spider_sleep
|
|
495
|
+
sleep 0.1
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|