git-fit 0.17.8 → 0.18.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.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +1 -0
- data/lib/git_fit/cli/elevation.rb +68 -0
- data/lib/git_fit/elevation/backfill.rb +64 -9
- data/lib/git_fit/elevation/data/dem_coeff.json +71 -0
- data/lib/git_fit/elevation/data/dem_lookup.json +258 -0
- data/lib/git_fit/elevation/terrain_coeff.rb +126 -0
- data/lib/git_fit/elevation/train/collector.rb +228 -0
- data/lib/git_fit/elevation/train/fitter.rb +166 -0
- data/lib/git_fit/elevation/train/runner.rb +66 -0
- data/lib/git_fit/sync/base.rb +55 -0
- data/lib/git_fit/sync/xingzhe.rb +3 -2
- data/lib/git_fit/version.rb +1 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1036c14a867417be89ed1c100aa71de434f38695c49540ef56bf2b7e7d197f9
|
|
4
|
+
data.tar.gz: 66b405c18d393b552ac0e1c0000b92a66f695c6939432af2228f7a4230fd38fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2b3c47c8b27144718d11303a7127ed6a7734e7e6015cca0c9c40ab52ec6ea08c07b1f9d5df7b9f491eeb5928f6034bc6a94e56cf5616954b0298dbbfae3e46f
|
|
7
|
+
data.tar.gz: 7f022ddad0be9ff9709310aef97f3ec4016224d719747cb0a5e6c5f7f484390dafa5257633161c187b5f8d51efaaafcc86f6e7480ff5d5f6e905db1287885932
|
data/lib/git-fit.rb
CHANGED
|
@@ -95,6 +95,7 @@ require_relative 'git_fit/cli/geo_cli'
|
|
|
95
95
|
require_relative 'git_fit/strava_web/file_check'
|
|
96
96
|
require_relative 'git_fit/cli/elevation'
|
|
97
97
|
require_relative 'git_fit/elevation/backfill'
|
|
98
|
+
require_relative 'git_fit/elevation/terrain_coeff'
|
|
98
99
|
require_relative 'git_fit/cli/strava'
|
|
99
100
|
require_relative 'git_fit/auth/garmin_token'
|
|
100
101
|
require_relative 'git_fit/auth/strava'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'thor'
|
|
4
|
+
require_relative '../elevation/train/runner'
|
|
4
5
|
|
|
5
6
|
module GitFit
|
|
6
7
|
class ElevationCLI < Thor
|
|
@@ -26,5 +27,72 @@ module GitFit
|
|
|
26
27
|
rescue StandardError => e
|
|
27
28
|
say_status :error, "Elevation backfill failed: #{e.message}", :red
|
|
28
29
|
end
|
|
30
|
+
|
|
31
|
+
desc 'train', 'Train DEM terrain coefficient model from activity data'
|
|
32
|
+
option :sources, type: :string, default: nil, desc: 'Comma-separated source adapters (default: all)'
|
|
33
|
+
option :output, type: :string, default: nil, desc: 'Output path for model file'
|
|
34
|
+
def train
|
|
35
|
+
sources = options[:sources]&.split(',')&.map(&:strip)
|
|
36
|
+
output = options[:output]
|
|
37
|
+
|
|
38
|
+
say_status :info, 'Starting model training...', :green
|
|
39
|
+
runner = GitFit::Elevation::Train::Runner.new(sources: sources, output: output)
|
|
40
|
+
success = runner.run
|
|
41
|
+
|
|
42
|
+
if success
|
|
43
|
+
say_status :done, 'Training complete', :green
|
|
44
|
+
else
|
|
45
|
+
say_status :error, 'Training failed', :red
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
say_status :error, "Training failed: #{e.message}", :red
|
|
50
|
+
say_status :error, e.backtrace.first(5).join("\n"), :red
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'infer GAIN_PER_KM RANGE_PER_KM SEG_DENSITY', 'Infer optimal h threshold for terrain'
|
|
55
|
+
def infer(gain_per_km, range_per_km, seg_density)
|
|
56
|
+
g = gain_per_km.to_f
|
|
57
|
+
r = range_per_km.to_f
|
|
58
|
+
s = seg_density.to_f
|
|
59
|
+
|
|
60
|
+
m = GitFit::Elevation::TerrainCoeff.terrain_M(
|
|
61
|
+
gain_per_km: g,
|
|
62
|
+
range_per_km: r,
|
|
63
|
+
seg_density: s
|
|
64
|
+
)
|
|
65
|
+
h = GitFit::Elevation::TerrainCoeff.hysteresis_threshold(m)
|
|
66
|
+
spacing = GitFit::Elevation::TerrainCoeff.dem_spacing(
|
|
67
|
+
gain_per_km: g,
|
|
68
|
+
range_per_km: r,
|
|
69
|
+
seg_density: s
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
say "terrain_M: #{format('%.3f', m)}"
|
|
73
|
+
say "h_threshold: #{format('%.1f', h)}"
|
|
74
|
+
say "dem_spacing: #{spacing}m"
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
say_status :error, "Inference failed: #{e.message}", :red
|
|
77
|
+
exit 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc 'model-info', 'Show information about the loaded DEM model'
|
|
81
|
+
def model_info
|
|
82
|
+
model = GitFit::Elevation::TerrainCoeff.model
|
|
83
|
+
path = GitFit::Elevation::TerrainCoeff.model_path
|
|
84
|
+
|
|
85
|
+
say "Model path: #{path}"
|
|
86
|
+
say "n_samples: #{model['meta']['n_samples']}"
|
|
87
|
+
say "trained_at: #{model['meta']['trained_at']}"
|
|
88
|
+
say "sources: #{model['meta']['sources'].join(', ')}"
|
|
89
|
+
say "ridge_h: #{model['ridge_h'].map { |w| format('%.4f', w) }.join(', ')}"
|
|
90
|
+
say "norms: #{model['norms'].map { |n| format('%.4f', n) }.join(', ')}"
|
|
91
|
+
say "pav_h entries: #{model['pav_h'].size}"
|
|
92
|
+
say "lookup entries: #{model['lookup'].size}"
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
say_status :error, "Failed to load model: #{e.message}", :red
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
29
97
|
end
|
|
30
98
|
end
|
|
@@ -5,6 +5,8 @@ module GitFit
|
|
|
5
5
|
class Backfill
|
|
6
6
|
SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe].freeze
|
|
7
7
|
BATCH_SIZE = 100
|
|
8
|
+
# Fallback threshold when detail.json elevation is absent (issue #83: baro 2m / GPS 3m).
|
|
9
|
+
ELEV_THRESHOLD = 2.0
|
|
8
10
|
|
|
9
11
|
def initialize(db, force: false)
|
|
10
12
|
@db = db
|
|
@@ -129,20 +131,47 @@ module GitFit
|
|
|
129
131
|
end
|
|
130
132
|
|
|
131
133
|
raw = JSON.parse(File.read(raw_path))
|
|
132
|
-
alts = raw.dig('stream', 'altitude') || []
|
|
134
|
+
alts = (raw.dig('stream', 'altitude') || raw.dig('data', 'altitude') || []).map(&:to_f)
|
|
133
135
|
return nil if alts.size < 2
|
|
134
136
|
|
|
137
|
+
# detail.json stores the inner API payload ({workout:...}); legacy wraps it in
|
|
138
|
+
# {code,data:{workout:...},msg}. Prefer xingzhe's own processed elevation.
|
|
139
|
+
detail = xingzhe_detail_workout(natural_id)
|
|
140
|
+
dg = detail && detail['elevation_gain']
|
|
141
|
+
dl = detail && detail['elevation_loss']
|
|
142
|
+
dm = detail && detail['max_altitude']
|
|
143
|
+
|
|
144
|
+
gain = detail_gain?(dg) ? dg : elevation_gain_from_alts(alts, threshold: ELEV_THRESHOLD)
|
|
145
|
+
loss = detail_gain?(dl) ? dl : elevation_loss_from_alts(alts, threshold: ELEV_THRESHOLD)
|
|
146
|
+
|
|
147
|
+
# max: detail max_altitude is reliable (matches stream max in samples); else raw max.
|
|
148
|
+
# min: stream is per-track baro-offset, so only drop low spikes, keep the bulk.
|
|
149
|
+
filtered = self.class.filter_low_outliers(alts)
|
|
135
150
|
{
|
|
136
|
-
elevation_gain:
|
|
137
|
-
elevation_loss:
|
|
138
|
-
elevation_min: alts.min&.round(1),
|
|
139
|
-
elevation_max: alts.max&.round(1),
|
|
151
|
+
elevation_gain: gain&.to_f&.round(1),
|
|
152
|
+
elevation_loss: loss&.to_f&.round(1),
|
|
153
|
+
elevation_min: (filtered.min || alts.min)&.round(1),
|
|
154
|
+
elevation_max: (dm.is_a?(Numeric) && dm > 0 ? dm : alts.max)&.round(1),
|
|
140
155
|
}
|
|
141
156
|
rescue StandardError => e
|
|
142
157
|
warn "Elevation backfill [xingzhe][#{natural_id}]: #{e.message}"
|
|
143
158
|
nil
|
|
144
159
|
end
|
|
145
160
|
|
|
161
|
+
def xingzhe_detail_workout(natural_id)
|
|
162
|
+
path = File.join('data', 'raw', 'xingzhe', natural_id, 'detail.json')
|
|
163
|
+
return nil unless File.exist?(path)
|
|
164
|
+
|
|
165
|
+
d = JSON.parse(File.read(path))
|
|
166
|
+
d['workout'] || d.dig('data', 'workout')
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def detail_gain?(value)
|
|
172
|
+
value.is_a?(Numeric) && !value.zero?
|
|
173
|
+
end
|
|
174
|
+
|
|
146
175
|
def fit_path_for(source, natural_id)
|
|
147
176
|
case source
|
|
148
177
|
when 'garmin', 'garmin_cn'
|
|
@@ -152,19 +181,19 @@ module GitFit
|
|
|
152
181
|
end
|
|
153
182
|
end
|
|
154
183
|
|
|
155
|
-
def elevation_gain_from_alts(alts)
|
|
184
|
+
def elevation_gain_from_alts(alts, threshold: 0.0)
|
|
156
185
|
return 0.0 if alts.size < 2
|
|
157
186
|
(1...alts.size).sum do |i|
|
|
158
187
|
d = alts[i] - alts[i - 1]
|
|
159
|
-
d >
|
|
188
|
+
d > threshold ? d : 0.0
|
|
160
189
|
end
|
|
161
190
|
end
|
|
162
191
|
|
|
163
|
-
def elevation_loss_from_alts(alts)
|
|
192
|
+
def elevation_loss_from_alts(alts, threshold: 0.0)
|
|
164
193
|
return 0.0 if alts.size < 2
|
|
165
194
|
(1...alts.size).sum do |i|
|
|
166
195
|
d = alts[i - 1] - alts[i]
|
|
167
|
-
d >
|
|
196
|
+
d > threshold ? d : 0.0
|
|
168
197
|
end
|
|
169
198
|
end
|
|
170
199
|
|
|
@@ -182,6 +211,32 @@ module GitFit
|
|
|
182
211
|
def skip_row(_run_id)
|
|
183
212
|
@skipped += 1
|
|
184
213
|
end
|
|
214
|
+
|
|
215
|
+
class << self
|
|
216
|
+
# Drop only implausibly-low altitude spikes (baro dropouts), leaving the
|
|
217
|
+
# genuinely shifted bulk intact. Lower fence = median - factor*1.4826*MAD.
|
|
218
|
+
def filter_low_outliers(altitudes, factor = 2.0)
|
|
219
|
+
return altitudes if altitudes.size < 4
|
|
220
|
+
|
|
221
|
+
med = median(altitudes)
|
|
222
|
+
m = mad(altitudes)
|
|
223
|
+
return altitudes if m.zero?
|
|
224
|
+
|
|
225
|
+
fence = med - factor * 1.4826 * m
|
|
226
|
+
altitudes.reject { |x| x < fence }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def median(values)
|
|
230
|
+
s = values.sort
|
|
231
|
+
n = s.size
|
|
232
|
+
n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def mad(values)
|
|
236
|
+
med = median(values)
|
|
237
|
+
median(values.map { |x| (x - med).abs })
|
|
238
|
+
end
|
|
239
|
+
end
|
|
185
240
|
end
|
|
186
241
|
end
|
|
187
242
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ridge_h": [
|
|
3
|
+
-9.077306137000443,
|
|
4
|
+
9.101690938636672,
|
|
5
|
+
9.807689833104916
|
|
6
|
+
],
|
|
7
|
+
"ridge_sp": [
|
|
8
|
+
-5602.015438422995,
|
|
9
|
+
5616.104008380678,
|
|
10
|
+
5818.3088293975225
|
|
11
|
+
],
|
|
12
|
+
"norms": [
|
|
13
|
+
77.2704957436896,
|
|
14
|
+
46.66281025695705,
|
|
15
|
+
14.46934427879514
|
|
16
|
+
],
|
|
17
|
+
"m_sp_min": 63.198201967279374,
|
|
18
|
+
"m_sp_max": 2332.5556989722154,
|
|
19
|
+
"pav_h": [
|
|
20
|
+
[
|
|
21
|
+
0.1995542278184373,
|
|
22
|
+
2.35
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
1.481813577725186,
|
|
26
|
+
2.5407407407407407
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
3.828455149726031,
|
|
30
|
+
3.0
|
|
31
|
+
]
|
|
32
|
+
],
|
|
33
|
+
"pav_sp": [
|
|
34
|
+
[
|
|
35
|
+
63.198201967279374,
|
|
36
|
+
181
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
79.26058601733692,
|
|
40
|
+
181
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
137.69617766559736,
|
|
44
|
+
181
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
189.56765019390437,
|
|
48
|
+
181
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
408.8898267340851,
|
|
52
|
+
484.1666666666667
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
941.936471891145,
|
|
56
|
+
1090.5
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
1007.4609047802492,
|
|
60
|
+
1090.5
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
1099.4112110983888,
|
|
64
|
+
1090.5
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
1501.4530064189416,
|
|
68
|
+
1818.1
|
|
69
|
+
]
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "56279644",
|
|
4
|
+
"gain_per_km": 4.765179852943763,
|
|
5
|
+
"range_per_km": 0.7625839940557885,
|
|
6
|
+
"seg_density": 1.8781327759159456,
|
|
7
|
+
"h_opt": 3.0,
|
|
8
|
+
"spacing_opt": 181
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "56076463",
|
|
12
|
+
"gain_per_km": 2.5838808834684825,
|
|
13
|
+
"range_per_km": 1.1287975976320341,
|
|
14
|
+
"seg_density": 0.9807431090537306,
|
|
15
|
+
"h_opt": 3.0,
|
|
16
|
+
"spacing_opt": 181
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "56368591",
|
|
20
|
+
"gain_per_km": 5.241385983727518,
|
|
21
|
+
"range_per_km": 4.475337263028882,
|
|
22
|
+
"seg_density": 1.8143259174441408,
|
|
23
|
+
"h_opt": 2.8,
|
|
24
|
+
"spacing_opt": 181
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "56796734",
|
|
28
|
+
"gain_per_km": 2.0541248195793695,
|
|
29
|
+
"range_per_km": 1.2965635861184979,
|
|
30
|
+
"seg_density": 0.9585915824703723,
|
|
31
|
+
"h_opt": 3.0,
|
|
32
|
+
"spacing_opt": 181
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "55976107",
|
|
36
|
+
"gain_per_km": 6.7209275220280364,
|
|
37
|
+
"range_per_km": 1.925247972449297,
|
|
38
|
+
"seg_density": 1.6589631225259078,
|
|
39
|
+
"h_opt": 2.4,
|
|
40
|
+
"spacing_opt": 181
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "180371339",
|
|
44
|
+
"gain_per_km": 25.97697790333317,
|
|
45
|
+
"range_per_km": 7.576618555138841,
|
|
46
|
+
"seg_density": 5.4118703965277435,
|
|
47
|
+
"h_opt": 2.7,
|
|
48
|
+
"spacing_opt": 2000
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "56401403",
|
|
52
|
+
"gain_per_km": 2.7229837022259686,
|
|
53
|
+
"range_per_km": 1.8266921193985366,
|
|
54
|
+
"seg_density": 0.2866298633922072,
|
|
55
|
+
"h_opt": 2.4,
|
|
56
|
+
"spacing_opt": 181
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "179672989",
|
|
60
|
+
"gain_per_km": 20.280751374432,
|
|
61
|
+
"range_per_km": 12.60695355707935,
|
|
62
|
+
"seg_density": 3.288770493151135,
|
|
63
|
+
"h_opt": 2.1,
|
|
64
|
+
"spacing_opt": 2000
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "56760049",
|
|
68
|
+
"gain_per_km": 5.749624549516914,
|
|
69
|
+
"range_per_km": 16.696909691797117,
|
|
70
|
+
"seg_density": 1.8398798558454126,
|
|
71
|
+
"h_opt": 3.0,
|
|
72
|
+
"spacing_opt": 181
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "179545783",
|
|
76
|
+
"gain_per_km": 1.8039361887638827,
|
|
77
|
+
"range_per_km": 7.648689440358862,
|
|
78
|
+
"seg_density": 0.6013120629212942,
|
|
79
|
+
"h_opt": 2.4,
|
|
80
|
+
"spacing_opt": 181
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "56368216",
|
|
84
|
+
"gain_per_km": 1.6348493836617806,
|
|
85
|
+
"range_per_km": 1.3127840550804097,
|
|
86
|
+
"seg_density": 0.7006497358550489,
|
|
87
|
+
"h_opt": 2.9,
|
|
88
|
+
"spacing_opt": 181
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "56773391",
|
|
92
|
+
"gain_per_km": 1.547936269252457,
|
|
93
|
+
"range_per_km": 2.0156341563194493,
|
|
94
|
+
"seg_density": 0.663401258251053,
|
|
95
|
+
"h_opt": 3.0,
|
|
96
|
+
"spacing_opt": 181
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "180413248",
|
|
100
|
+
"gain_per_km": 21.454009754423105,
|
|
101
|
+
"range_per_km": 12.872405852653861,
|
|
102
|
+
"seg_density": 3.5756682924038503,
|
|
103
|
+
"h_opt": 2.1,
|
|
104
|
+
"spacing_opt": 2000
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "55976106",
|
|
108
|
+
"gain_per_km": 34.171060177704426,
|
|
109
|
+
"range_per_km": 22.758524258835656,
|
|
110
|
+
"seg_density": 1.7945414535337114,
|
|
111
|
+
"h_opt": 2.7,
|
|
112
|
+
"spacing_opt": 2000
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"id": "179764068",
|
|
116
|
+
"gain_per_km": 21.476015892251763,
|
|
117
|
+
"range_per_km": 13.80601021644756,
|
|
118
|
+
"seg_density": 3.06800227032168,
|
|
119
|
+
"h_opt": 2.1,
|
|
120
|
+
"spacing_opt": 2000
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "59677573",
|
|
124
|
+
"gain_per_km": 2.221466453476413,
|
|
125
|
+
"range_per_km": 1.0819070549014291,
|
|
126
|
+
"seg_density": 0.6347047009932608,
|
|
127
|
+
"h_opt": 2.8,
|
|
128
|
+
"spacing_opt": 2000
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "62178115",
|
|
132
|
+
"gain_per_km": 1.665072953982141,
|
|
133
|
+
"range_per_km": 1.4533708212615546,
|
|
134
|
+
"seg_density": 0.5946689121364789,
|
|
135
|
+
"h_opt": 2.4,
|
|
136
|
+
"spacing_opt": 181
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "56736143",
|
|
140
|
+
"gain_per_km": 6.182563686588538,
|
|
141
|
+
"range_per_km": 10.307570178280413,
|
|
142
|
+
"seg_density": 1.4838152847812491,
|
|
143
|
+
"h_opt": 2.0,
|
|
144
|
+
"spacing_opt": 2000
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "179741390",
|
|
148
|
+
"gain_per_km": 15.338810730874666,
|
|
149
|
+
"range_per_km": 15.732113570127863,
|
|
150
|
+
"seg_density": 2.753119874772376,
|
|
151
|
+
"h_opt": 2.1,
|
|
152
|
+
"spacing_opt": 2000
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "60509717",
|
|
156
|
+
"gain_per_km": 3.755389344804926,
|
|
157
|
+
"range_per_km": 1.178662648079095,
|
|
158
|
+
"seg_density": 0.48146017241088795,
|
|
159
|
+
"h_opt": 2.9,
|
|
160
|
+
"spacing_opt": 181
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"id": "180371336",
|
|
164
|
+
"gain_per_km": 22.172949002217294,
|
|
165
|
+
"range_per_km": 7.390983000739098,
|
|
166
|
+
"seg_density": 4.106101667077277,
|
|
167
|
+
"h_opt": 2.1,
|
|
168
|
+
"spacing_opt": 2000
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "60220082",
|
|
172
|
+
"gain_per_km": 1.441196526868075,
|
|
173
|
+
"range_per_km": 0.9058299433609764,
|
|
174
|
+
"seg_density": 0.6826720390427724,
|
|
175
|
+
"h_opt": 2.5,
|
|
176
|
+
"spacing_opt": 2000
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"id": "179909296",
|
|
180
|
+
"gain_per_km": 19.591333127693808,
|
|
181
|
+
"range_per_km": 8.3367375011463,
|
|
182
|
+
"seg_density": 3.7515318755158353,
|
|
183
|
+
"h_opt": 2.1,
|
|
184
|
+
"spacing_opt": 2000
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"id": "180371341",
|
|
188
|
+
"gain_per_km": 20.886092473174426,
|
|
189
|
+
"range_per_km": 7.83228467744041,
|
|
190
|
+
"seg_density": 4.351269265244672,
|
|
191
|
+
"h_opt": 2.0,
|
|
192
|
+
"spacing_opt": 2000
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"id": "56700083",
|
|
196
|
+
"gain_per_km": 0.8884413776764299,
|
|
197
|
+
"range_per_km": 0.5341013415464805,
|
|
198
|
+
"seg_density": 0.19743141726142888,
|
|
199
|
+
"h_opt": 2.1,
|
|
200
|
+
"spacing_opt": 181
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "61808448",
|
|
204
|
+
"gain_per_km": 1.1360659928112273,
|
|
205
|
+
"range_per_km": 0.7833175020433414,
|
|
206
|
+
"seg_density": 0.4418034416488107,
|
|
207
|
+
"h_opt": 2.0,
|
|
208
|
+
"spacing_opt": 181
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"id": "179802794",
|
|
212
|
+
"gain_per_km": 22.273614770723942,
|
|
213
|
+
"range_per_km": 9.004227247739466,
|
|
214
|
+
"seg_density": 5.212973669743901,
|
|
215
|
+
"h_opt": 2.0,
|
|
216
|
+
"spacing_opt": 2000
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"id": "179709155",
|
|
220
|
+
"gain_per_km": 21.558002680184117,
|
|
221
|
+
"range_per_km": 6.409135931946629,
|
|
222
|
+
"seg_density": 5.243838489774515,
|
|
223
|
+
"h_opt": 2.8,
|
|
224
|
+
"spacing_opt": 2000
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"id": "28381323",
|
|
228
|
+
"gain_per_km": 5.663397691876494,
|
|
229
|
+
"range_per_km": 5.132309683832157,
|
|
230
|
+
"seg_density": 1.8492727157147735,
|
|
231
|
+
"h_opt": 2.9,
|
|
232
|
+
"spacing_opt": 181
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"id": "59677574",
|
|
236
|
+
"gain_per_km": 2.483839907960171,
|
|
237
|
+
"range_per_km": 0.51876770649111,
|
|
238
|
+
"seg_density": 0.9183945878172062,
|
|
239
|
+
"h_opt": 3.0,
|
|
240
|
+
"spacing_opt": 181
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"id": "56151030",
|
|
244
|
+
"gain_per_km": 2.193824384358032,
|
|
245
|
+
"range_per_km": 3.9247518236165186,
|
|
246
|
+
"seg_density": 1.9744419459222284,
|
|
247
|
+
"h_opt": 2.9,
|
|
248
|
+
"spacing_opt": 181
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"id": "56300530",
|
|
252
|
+
"gain_per_km": 5.034207439551756,
|
|
253
|
+
"range_per_km": 1.833613248175197,
|
|
254
|
+
"seg_density": 1.858784285372956,
|
|
255
|
+
"h_opt": 2.8,
|
|
256
|
+
"spacing_opt": 181
|
|
257
|
+
}
|
|
258
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'msgpack'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module Elevation
|
|
9
|
+
module TerrainCoeff
|
|
10
|
+
CACHE_DIR = ENV.fetch('GIT_FIT_DEM_CACHE_DIR', 'data/cache/dem_fit')
|
|
11
|
+
DEM_CACHE_DIR = File.join(CACHE_DIR, 'cache')
|
|
12
|
+
BUNDLED_MODEL_DIR = File.expand_path('model', __dir__)
|
|
13
|
+
BUNDLED_MODEL = File.join(BUNDLED_MODEL_DIR, 'dem_model.msgpack')
|
|
14
|
+
MODEL_FILE = 'dem_model.msgpack'
|
|
15
|
+
DEM_URL = 'https://api.opentopodata.org/v1/srtm30m'
|
|
16
|
+
GRID = 0.0003
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def model_path
|
|
20
|
+
@model_path ||= find_model
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def model
|
|
24
|
+
@model ||= MessagePack.unpack(File.read(model_path))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_model
|
|
28
|
+
cache_path = File.join(CACHE_DIR, MODEL_FILE)
|
|
29
|
+
return cache_path if File.exist?(cache_path)
|
|
30
|
+
return BUNDLED_MODEL if File.exist?(BUNDLED_MODEL)
|
|
31
|
+
raise "No #{MODEL_FILE} found in cache (#{CACHE_DIR}) or bundled (#{BUNDLED_MODEL})"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset!
|
|
35
|
+
@model_path = nil
|
|
36
|
+
@model = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def terrain_M(gain_per_km:, range_per_km:, seg_density:)
|
|
40
|
+
g = gain_per_km.to_f
|
|
41
|
+
r = range_per_km.to_f
|
|
42
|
+
s = seg_density.to_f
|
|
43
|
+
norms = model['norms']
|
|
44
|
+
g_n = g / norms[0]
|
|
45
|
+
r_n = r / norms[1]
|
|
46
|
+
s_n = s / norms[2]
|
|
47
|
+
w = model['ridge_h']
|
|
48
|
+
g_n * w[0] + r_n * w[1] + s_n * w[2]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def hysteresis_threshold(m)
|
|
52
|
+
return 2.0 if m <= 0
|
|
53
|
+
pav = model['pav_h']
|
|
54
|
+
return 2.0 if m <= pav[0][0]
|
|
55
|
+
return pav[-1][1] if m >= pav[-1][0]
|
|
56
|
+
pav.each_with_index do |(xk, yk), i|
|
|
57
|
+
next unless i + 1 < pav.size
|
|
58
|
+
return yk if m <= pav[i + 1][0]
|
|
59
|
+
end
|
|
60
|
+
pav[-1][1]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dem_spacing(gain_per_km:, range_per_km:, seg_density:)
|
|
64
|
+
nearest_neighbor_spacing(gain_per_km, range_per_km, seg_density)
|
|
65
|
+
rescue => e
|
|
66
|
+
warn "TerrainCoeff: dem_spacing fallback to 181m (#{e.message})"
|
|
67
|
+
181
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def elevations_for(locations)
|
|
71
|
+
uniq = {}
|
|
72
|
+
locations.each { |lat, lng| uniq[grid_key(lat, lng)] ||= [lat, lng] }
|
|
73
|
+
uniq_vals = uniq.values
|
|
74
|
+
|
|
75
|
+
elevs = {}
|
|
76
|
+
uniq_vals.each_slice(100).with_index do |slice, bi|
|
|
77
|
+
qs = slice.map { |la, ln| "#{la},#{ln}" }.join('|')
|
|
78
|
+
uri = URI("#{DEM_URL}?locations=#{URI.encode_www_form_component(qs)}")
|
|
79
|
+
begin
|
|
80
|
+
res = Net::HTTP.get_response(uri)
|
|
81
|
+
if res.code == '200'
|
|
82
|
+
results = JSON.parse(res.body)['results']
|
|
83
|
+
slice.each_with_index do |(lat, lng), i|
|
|
84
|
+
elevs[grid_key(lat, lng)] = results[i]['elevation'] if results[i]
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
warn "DEM batch #{bi} HTTP #{res.code}"
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
warn "DEM batch #{bi}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
sleep 0.15
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
locations.map { |lat, lng| elevs[grid_key(lat, lng)] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def grid_key(lat, lng)
|
|
99
|
+
"#{(lat / GRID).round}/#{(lng / GRID).round}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def lookup
|
|
103
|
+
model['lookup']
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def nearest_neighbor_spacing(gain_per_km, range_per_km, seg_density)
|
|
109
|
+
best = nil
|
|
110
|
+
best_dist = Float::INFINITY
|
|
111
|
+
lookup.each do |ref|
|
|
112
|
+
d = Math.sqrt(
|
|
113
|
+
(gain_per_km.to_f - ref['gk'])**2 +
|
|
114
|
+
(range_per_km.to_f - ref['rk'])**2 +
|
|
115
|
+
(seg_density.to_f - ref['sd'])**2
|
|
116
|
+
)
|
|
117
|
+
next unless d < best_dist
|
|
118
|
+
best_dist = d
|
|
119
|
+
best = ref
|
|
120
|
+
end
|
|
121
|
+
best ? best['sp'] : 181
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../terrain_coeff'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Elevation
|
|
7
|
+
module Train
|
|
8
|
+
class Collector
|
|
9
|
+
SPACINGS = [2000, 1236, 764, 472, 292, 181].freeze
|
|
10
|
+
BUDGET = 2.0
|
|
11
|
+
|
|
12
|
+
def initialize(sources: nil)
|
|
13
|
+
@sources = sources
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def collect
|
|
17
|
+
samples = []
|
|
18
|
+
each_source_sample do |sample|
|
|
19
|
+
result = evaluate_sample(sample)
|
|
20
|
+
samples << result if result
|
|
21
|
+
end
|
|
22
|
+
samples
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each_source_sample(&block)
|
|
26
|
+
adapters = @sources ? resolve_sources(@sources) : all_adapters
|
|
27
|
+
adapters.each do |adapter|
|
|
28
|
+
begin
|
|
29
|
+
adapter.training_samples.each(&block)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
warn "Collector: #{adapter.name} failed: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_adapters
|
|
37
|
+
Sync::Base.adapters
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_sources(names)
|
|
41
|
+
names.map do |name|
|
|
42
|
+
Sync::Base.adapters.find { |a| a.name.demodulize.downcase == name.to_s.downcase }
|
|
43
|
+
end.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def evaluate_sample(sample)
|
|
47
|
+
altitudes = sample[:altitudes]
|
|
48
|
+
distances = sample[:distances]
|
|
49
|
+
locations = sample[:locations]
|
|
50
|
+
source = sample[:source]
|
|
51
|
+
id = sample[:id]
|
|
52
|
+
|
|
53
|
+
return nil if altitudes.size < 2
|
|
54
|
+
return nil if distances.empty? || distances.last < 1000
|
|
55
|
+
return nil if sample[:altitudes].size < 2
|
|
56
|
+
|
|
57
|
+
dem = TerrainCoeff.elevations_for(locations)
|
|
58
|
+
return nil if dem.empty? || dem.compact.size < 2
|
|
59
|
+
|
|
60
|
+
truth_min = dem.compact.min
|
|
61
|
+
truth_max = dem.compact.max
|
|
62
|
+
sm = smooth(altitudes)
|
|
63
|
+
|
|
64
|
+
best = nil
|
|
65
|
+
(2.0..3.0).step(0.1).each do |h|
|
|
66
|
+
h = h.round(2)
|
|
67
|
+
segs = segments(sm, h)
|
|
68
|
+
chosen = nil
|
|
69
|
+
SPACINGS.each do |sp|
|
|
70
|
+
sel = sample_indices(sm, segs, distances, sp)
|
|
71
|
+
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
72
|
+
err = [ (lmin - truth_min).abs, (lmax - truth_max).abs ].max
|
|
73
|
+
if err <= BUDGET
|
|
74
|
+
chosen = { h: h, spacing: sp, err: err, npts: npts }
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
unless chosen
|
|
79
|
+
sel = sample_indices(sm, segs, distances, SPACINGS.last)
|
|
80
|
+
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
81
|
+
err = [ (lmin - truth_min).abs, (lmax - truth_max).abs ].max
|
|
82
|
+
chosen = { h: h, spacing: SPACINGS.last, err: err, npts: npts }
|
|
83
|
+
end
|
|
84
|
+
best = chosen if best.nil? || chosen[:npts] < best[:npts]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return nil unless best
|
|
88
|
+
|
|
89
|
+
f = features(altitudes, distances, locations)
|
|
90
|
+
{
|
|
91
|
+
id: id,
|
|
92
|
+
source: source,
|
|
93
|
+
truth_min: truth_min,
|
|
94
|
+
truth_max: truth_max,
|
|
95
|
+
h_opt: best[:h],
|
|
96
|
+
spacing_opt: best[:spacing],
|
|
97
|
+
err: best[:err].round(2),
|
|
98
|
+
npts: best[:npts],
|
|
99
|
+
gain_per_km: f[:gain_per_km],
|
|
100
|
+
range_per_km: f[:range_per_km],
|
|
101
|
+
seg_density: f[:seg_density]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def features(altitudes, distances, locations)
|
|
106
|
+
return nil if altitudes.empty? || distances.empty?
|
|
107
|
+
|
|
108
|
+
dm = distances.last
|
|
109
|
+
km = [dm / 1000.0, 1e-6].max
|
|
110
|
+
gain = elevation_gain(altitudes)
|
|
111
|
+
range = altitudes.empty? ? 0.0 : (altitudes.max - altitudes.min)
|
|
112
|
+
gain_per_km = gain / km
|
|
113
|
+
range_per_km = range / km
|
|
114
|
+
seg_density = segments(smooth(altitudes), 2.5).size / km
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
gain_per_km: gain_per_km,
|
|
118
|
+
range_per_km: range_per_km,
|
|
119
|
+
seg_density: seg_density,
|
|
120
|
+
distance_m: dm,
|
|
121
|
+
npts: altitudes.size
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def elevation_gain(alts)
|
|
126
|
+
total = 0.0
|
|
127
|
+
(1...alts.size).each do |i|
|
|
128
|
+
diff = alts[i] - alts[i - 1]
|
|
129
|
+
total += diff if diff > 0
|
|
130
|
+
end
|
|
131
|
+
total
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def smooth(vals)
|
|
135
|
+
moving_avg(moving_median(vals, 9), 9)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def moving_median(vals, w)
|
|
139
|
+
half = w / 2
|
|
140
|
+
(0...vals.size).map do |i|
|
|
141
|
+
lo = [0, i - half].max
|
|
142
|
+
hi = [vals.size - 1, i + half].min
|
|
143
|
+
vals[lo..hi].sort[(hi - lo) / 2]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def moving_avg(vals, w)
|
|
148
|
+
half = w / 2
|
|
149
|
+
(0...vals.size).map do |i|
|
|
150
|
+
lo = [0, i - half].max
|
|
151
|
+
hi = [vals.size - 1, i + half].min
|
|
152
|
+
vals[lo..hi].sum.to_f / (hi - lo + 1)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def segments(smooth, h, min_len: 20)
|
|
157
|
+
n = smooth.size
|
|
158
|
+
return [[0, n - 1]] if n < 2
|
|
159
|
+
|
|
160
|
+
segs = []
|
|
161
|
+
seg_start = 0
|
|
162
|
+
ext_val = smooth[0]
|
|
163
|
+
ext_idx = 0
|
|
164
|
+
rising = nil
|
|
165
|
+
(1...n).each do |i|
|
|
166
|
+
v = smooth[i]
|
|
167
|
+
if rising.nil?
|
|
168
|
+
if v > ext_val
|
|
169
|
+
ext_val = v; ext_idx = i; rising = true
|
|
170
|
+
elsif v < ext_val
|
|
171
|
+
ext_val = v; ext_idx = i; rising = false
|
|
172
|
+
end
|
|
173
|
+
elsif rising
|
|
174
|
+
if v > ext_val
|
|
175
|
+
ext_val = v; ext_idx = i
|
|
176
|
+
elsif ext_val - v >= h
|
|
177
|
+
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
178
|
+
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
179
|
+
rising = false
|
|
180
|
+
ext_val = v; ext_idx = i
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
if v < ext_val
|
|
184
|
+
ext_val = v; ext_idx = i
|
|
185
|
+
elsif v - ext_val >= h
|
|
186
|
+
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
187
|
+
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
188
|
+
rising = true
|
|
189
|
+
ext_val = v; ext_idx = i
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
segs << [seg_start, n - 1] if n - 1 - seg_start >= min_len
|
|
194
|
+
segs.empty? ? [[0, n - 1]] : segs
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sample_indices(smooth, segs, dist, spacing)
|
|
198
|
+
sel = Set.new
|
|
199
|
+
segs.each { |a, b| sel << a; sel << b }
|
|
200
|
+
segs.each do |a, b|
|
|
201
|
+
next if b - a < 2
|
|
202
|
+
|
|
203
|
+
d0 = dist[a].to_f
|
|
204
|
+
d1 = dist[b].to_f
|
|
205
|
+
len = d1 - d0
|
|
206
|
+
seg_range = smooth[a..b].max - smooth[a..b].min
|
|
207
|
+
next unless len > 500 && seg_range < 3.0
|
|
208
|
+
|
|
209
|
+
k = 1
|
|
210
|
+
while d0 + k * spacing < d1
|
|
211
|
+
target = d0 + k * spacing
|
|
212
|
+
idx = a + ((b - a) * (target - d0) / len).round
|
|
213
|
+
sel << idx
|
|
214
|
+
k += 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
sel.to_a.sort
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def layered_estimate(dem, sel)
|
|
221
|
+
vals = sel.map { |i| dem[i] }.compact
|
|
222
|
+
return [nil, nil, 0] if vals.empty?
|
|
223
|
+
[vals.min, vals.max, vals.size]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'msgpack'
|
|
4
|
+
require_relative '../terrain_coeff'
|
|
5
|
+
|
|
6
|
+
module GitFit
|
|
7
|
+
module Elevation
|
|
8
|
+
module Train
|
|
9
|
+
class Fitter
|
|
10
|
+
def initialize(samples)
|
|
11
|
+
@samples = samples
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fit
|
|
15
|
+
return nil if @samples.empty?
|
|
16
|
+
|
|
17
|
+
ridge_h = fit_ridge_h
|
|
18
|
+
norms = compute_norms
|
|
19
|
+
pav_h = fit_pav_h
|
|
20
|
+
lookup = build_lookup
|
|
21
|
+
meta = build_meta
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
ridge_h: ridge_h,
|
|
25
|
+
norms: norms,
|
|
26
|
+
pav_h: pav_h,
|
|
27
|
+
lookup: lookup,
|
|
28
|
+
meta: meta
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_msgpack(path)
|
|
33
|
+
model = fit
|
|
34
|
+
return false unless model
|
|
35
|
+
|
|
36
|
+
dir = File.dirname(path)
|
|
37
|
+
FileUtils.mkdir_p(dir)
|
|
38
|
+
tmp = "#{path}.tmp"
|
|
39
|
+
File.write(tmp, model.to_msgpack)
|
|
40
|
+
File.rename(tmp, path)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def fit_ridge_h
|
|
47
|
+
g = @samples.map { |s| s[:gain_per_km] }
|
|
48
|
+
r = @samples.map { |s| s[:range_per_km] }
|
|
49
|
+
sd = @samples.map { |s| s[:seg_density] }
|
|
50
|
+
h = @samples.map { |s| s[:h_opt] }
|
|
51
|
+
|
|
52
|
+
n = g.size
|
|
53
|
+
return [0, 0, 0] if n < 2
|
|
54
|
+
|
|
55
|
+
g_mean = g.sum.to_f / n
|
|
56
|
+
r_mean = r.sum.to_f / n
|
|
57
|
+
sd_mean = sd.sum.to_f / n
|
|
58
|
+
h_mean = h.sum.to_f / n
|
|
59
|
+
|
|
60
|
+
g_std = Math.sqrt(g.map { |x| (x - g_mean)**2 }.sum / n)
|
|
61
|
+
r_std = Math.sqrt(r.map { |x| (x - r_mean)**2 }.sum / n)
|
|
62
|
+
sd_std = Math.sqrt(sd.map { |x| (x - sd_mean)**2 }.sum / n)
|
|
63
|
+
|
|
64
|
+
g_std = 1.0 if g_std.zero?
|
|
65
|
+
r_std = 1.0 if r_std.zero?
|
|
66
|
+
sd_std = 1.0 if sd_std.zero?
|
|
67
|
+
|
|
68
|
+
g_n = g.map { |x| (x - g_mean) / g_std }
|
|
69
|
+
r_n = r.map { |x| (x - r_mean) / r_std }
|
|
70
|
+
sd_n = sd.map { |x| (x - sd_mean) / sd_std }
|
|
71
|
+
|
|
72
|
+
numerator0 = (0...n).sum { |i| g_n[i] * (h[i] - h_mean) }
|
|
73
|
+
numerator1 = (0...n).sum { |i| r_n[i] * (h[i] - h_mean) }
|
|
74
|
+
numerator2 = (0...n).sum { |i| sd_n[i] * (h[i] - h_mean) }
|
|
75
|
+
|
|
76
|
+
denominator = (0...n).sum { |i| g_n[i]**2 }
|
|
77
|
+
w0 = numerator0 / denominator
|
|
78
|
+
w1 = numerator1 / denominator
|
|
79
|
+
w2 = numerator2 / denominator
|
|
80
|
+
|
|
81
|
+
[w0, w1, w2]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def compute_norms
|
|
85
|
+
g = @samples.map { |s| s[:gain_per_km] }
|
|
86
|
+
r = @samples.map { |s| s[:range_per_km] }
|
|
87
|
+
sd = @samples.map { |s| s[:seg_density] }
|
|
88
|
+
|
|
89
|
+
n = g.size
|
|
90
|
+
return [1.0, 1.0, 1.0] if n < 2
|
|
91
|
+
|
|
92
|
+
g_mean = g.sum.to_f / n
|
|
93
|
+
r_mean = r.sum.to_f / n
|
|
94
|
+
sd_mean = sd.sum.to_f / n
|
|
95
|
+
|
|
96
|
+
g_std = Math.sqrt(g.map { |x| (x - g_mean)**2 }.sum / n)
|
|
97
|
+
r_std = Math.sqrt(r.map { |x| (x - r_mean)**2 }.sum / n)
|
|
98
|
+
sd_std = Math.sqrt(sd.map { |x| (x - sd_mean)**2 }.sum / n)
|
|
99
|
+
|
|
100
|
+
[g_std, r_std, sd_std]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fit_pav_h
|
|
104
|
+
m_values = @samples.map { |s| terrain_M(s) }
|
|
105
|
+
h_values = @samples.map { |s| s[:h_opt] }
|
|
106
|
+
|
|
107
|
+
sorted = @samples.map.with_index { |_, i| [m_values[i], h_values[i]] }.sort_by(&:first)
|
|
108
|
+
|
|
109
|
+
pav = []
|
|
110
|
+
current_m = nil
|
|
111
|
+
current_h = nil
|
|
112
|
+
count = 0
|
|
113
|
+
|
|
114
|
+
sorted.each do |m, h|
|
|
115
|
+
if current_m.nil? || (m - current_m).abs < 0.01
|
|
116
|
+
current_m = m unless current_m
|
|
117
|
+
current_h ||= h
|
|
118
|
+
current_h = [current_h, h].min
|
|
119
|
+
count += 1
|
|
120
|
+
else
|
|
121
|
+
pav << [current_m, current_h.round(1)] if count > 0
|
|
122
|
+
current_m = m
|
|
123
|
+
current_h = h
|
|
124
|
+
count = 1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
pav << [current_m, current_h.round(1)] if count > 0
|
|
128
|
+
|
|
129
|
+
pav
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def terrain_M(sample)
|
|
133
|
+
g = sample[:gain_per_km].to_f
|
|
134
|
+
r = sample[:range_per_km].to_f
|
|
135
|
+
s = sample[:seg_density].to_f
|
|
136
|
+
norms = compute_norms
|
|
137
|
+
g_n = g / norms[0]
|
|
138
|
+
r_n = r / norms[1]
|
|
139
|
+
s_n = s / norms[2]
|
|
140
|
+
w = fit_ridge_h
|
|
141
|
+
g_n * w[0] + r_n * w[1] + s_n * w[2]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_lookup
|
|
145
|
+
@samples.map do |s|
|
|
146
|
+
{
|
|
147
|
+
'gk' => s[:gain_per_km],
|
|
148
|
+
'rk' => s[:range_per_km],
|
|
149
|
+
'sd' => s[:seg_density],
|
|
150
|
+
'h' => s[:h_opt],
|
|
151
|
+
'sp' => s[:spacing_opt]
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_meta
|
|
157
|
+
{
|
|
158
|
+
'n_samples' => @samples.size,
|
|
159
|
+
'trained_at' => Time.now.strftime('%Y-%m-%d'),
|
|
160
|
+
'sources' => @samples.map { |s| s[:source] }.uniq
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'collector'
|
|
4
|
+
require_relative 'fitter'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module Elevation
|
|
9
|
+
module Train
|
|
10
|
+
class Runner
|
|
11
|
+
CACHE_DIR = 'data/cache/dem_fit'
|
|
12
|
+
MODEL_FILE = 'dem_model.msgpack'
|
|
13
|
+
|
|
14
|
+
def initialize(sources: nil, output: nil)
|
|
15
|
+
@sources = sources
|
|
16
|
+
@output = output || default_output_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
puts "Collecting samples..."
|
|
21
|
+
collector = Collector.new(sources: @sources)
|
|
22
|
+
samples = collector.collect
|
|
23
|
+
|
|
24
|
+
if samples.empty?
|
|
25
|
+
warn "No samples collected"
|
|
26
|
+
return false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "Collected #{samples.size} samples"
|
|
30
|
+
|
|
31
|
+
puts "Fitting model..."
|
|
32
|
+
fitter = Fitter.new(samples)
|
|
33
|
+
model = fitter.fit
|
|
34
|
+
|
|
35
|
+
unless model
|
|
36
|
+
warn "Fitting failed"
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts "Writing model to #{@output}..."
|
|
41
|
+
unless fitter.write_msgpack(@output)
|
|
42
|
+
warn "Failed to write model"
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts "Model trained successfully!"
|
|
47
|
+
puts " n_samples: #{model[:meta]['n_samples']}"
|
|
48
|
+
puts " sources: #{model[:meta]['sources'].join(', ')}"
|
|
49
|
+
puts " ridge_h: #{model[:ridge_h].map { |w| format('%.2f', w) }.join(', ')}"
|
|
50
|
+
puts " output: #{@output}"
|
|
51
|
+
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_output_path
|
|
56
|
+
File.join(CACHE_DIR, MODEL_FILE)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.run!(sources: nil, output: nil)
|
|
60
|
+
runner = new(sources: sources, output: output)
|
|
61
|
+
runner.run
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/git_fit/sync/base.rb
CHANGED
|
@@ -208,6 +208,61 @@ module GitFit
|
|
|
208
208
|
raise NotImplementedError, "#{self.class} must implement #raw_path"
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
def self.training_samples
|
|
212
|
+
std = File.join('data', 'std', config_key)
|
|
213
|
+
return [] unless Dir.exist?(std)
|
|
214
|
+
|
|
215
|
+
Dir.children(std).filter_map do |f|
|
|
216
|
+
next unless f.end_with?('.json')
|
|
217
|
+
|
|
218
|
+
pts = JSON.parse(File.read(File.join(std, f))) rescue next
|
|
219
|
+
next unless pts.is_a?(Array) && pts.any?
|
|
220
|
+
|
|
221
|
+
altitudes = pts.filter_map { |p| p['altitude']&.to_f }
|
|
222
|
+
next if altitudes.size < 2
|
|
223
|
+
|
|
224
|
+
locations = pts.filter_map { |p| extract_location(p) }
|
|
225
|
+
next if locations.size < 2
|
|
226
|
+
|
|
227
|
+
distances = cumulative_distances(locations)
|
|
228
|
+
|
|
229
|
+
{
|
|
230
|
+
altitudes: altitudes,
|
|
231
|
+
distances: distances,
|
|
232
|
+
locations: locations,
|
|
233
|
+
source: config_key,
|
|
234
|
+
id: File.basename(f, '.json')
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.extract_location(p)
|
|
240
|
+
lat = p['positionLat'] || p['latitude'] || p['lat']
|
|
241
|
+
lng = p['positionLong'] || p['longitude'] || p['lng']
|
|
242
|
+
return nil unless lat && lng
|
|
243
|
+
[lng.to_f, lat.to_f]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.cumulative_distances(locations)
|
|
247
|
+
return [] if locations.size < 2
|
|
248
|
+
|
|
249
|
+
dists = [0.0]
|
|
250
|
+
(1...locations.size).each do |i|
|
|
251
|
+
dists << dists.last + haversine(locations[i - 1], locations[i])
|
|
252
|
+
end
|
|
253
|
+
dists
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.haversine(a, b)
|
|
257
|
+
r = 6_371_000.0
|
|
258
|
+
dlat = (b[0] - a[0]) * Math::PI / 180
|
|
259
|
+
dlon = (b[1] - a[1]) * Math::PI / 180
|
|
260
|
+
la1 = a[0] * Math::PI / 180
|
|
261
|
+
la2 = b[0] * Math::PI / 180
|
|
262
|
+
h = Math.sin(dlat / 2)**2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dlon / 2)**2
|
|
263
|
+
2 * r * Math.asin(Math.sqrt(h))
|
|
264
|
+
end
|
|
265
|
+
|
|
211
266
|
def write_source_archive(data, platform_id, ext = 'json')
|
|
212
267
|
dir = raw_dir
|
|
213
268
|
FileUtils.mkdir_p(dir)
|
data/lib/git_fit/sync/xingzhe.rb
CHANGED
|
@@ -352,8 +352,9 @@ module GitFit
|
|
|
352
352
|
end
|
|
353
353
|
|
|
354
354
|
def derive_elevation_min(stream, detail_workout)
|
|
355
|
-
if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.
|
|
356
|
-
|
|
355
|
+
if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.size >= 2
|
|
356
|
+
alts = alt.compact.map(&:to_f)
|
|
357
|
+
v = GitFit::Elevation::Backfill.filter_low_outliers(alts).min
|
|
357
358
|
return v.to_f if v
|
|
358
359
|
end
|
|
359
360
|
if detail_workout && (segs = detail_workout['segments_km']) && segs.is_a?(Array) && segs.any?
|
data/lib/git_fit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-fit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -233,6 +233,20 @@ dependencies:
|
|
|
233
233
|
- - "~>"
|
|
234
234
|
- !ruby/object:Gem::Version
|
|
235
235
|
version: '3.0'
|
|
236
|
+
- !ruby/object:Gem::Dependency
|
|
237
|
+
name: msgpack
|
|
238
|
+
requirement: !ruby/object:Gem::Requirement
|
|
239
|
+
requirements:
|
|
240
|
+
- - "~>"
|
|
241
|
+
- !ruby/object:Gem::Version
|
|
242
|
+
version: '1.0'
|
|
243
|
+
type: :runtime
|
|
244
|
+
prerelease: false
|
|
245
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
246
|
+
requirements:
|
|
247
|
+
- - "~>"
|
|
248
|
+
- !ruby/object:Gem::Version
|
|
249
|
+
version: '1.0'
|
|
236
250
|
description: A git extension CLI for aggregating, managing, and exporting fitness
|
|
237
251
|
activity data from multiple sources (Garmin, Strava, Keep, etc.)
|
|
238
252
|
email:
|
|
@@ -279,6 +293,12 @@ files:
|
|
|
279
293
|
- lib/git_fit/dedup/service.rb
|
|
280
294
|
- lib/git_fit/dedup/trajectory.rb
|
|
281
295
|
- lib/git_fit/elevation/backfill.rb
|
|
296
|
+
- lib/git_fit/elevation/data/dem_coeff.json
|
|
297
|
+
- lib/git_fit/elevation/data/dem_lookup.json
|
|
298
|
+
- lib/git_fit/elevation/terrain_coeff.rb
|
|
299
|
+
- lib/git_fit/elevation/train/collector.rb
|
|
300
|
+
- lib/git_fit/elevation/train/fitter.rb
|
|
301
|
+
- lib/git_fit/elevation/train/runner.rb
|
|
282
302
|
- lib/git_fit/export.rb
|
|
283
303
|
- lib/git_fit/export/calculator.rb
|
|
284
304
|
- lib/git_fit/export/csv.rb
|