git-fit 0.17.9 → 0.18.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 +1 -0
- data/lib/git_fit/cli/elevation.rb +68 -0
- 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 +233 -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/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: a0433b30c8c6ae304f3cca0d0bdc83d32579566265383eff605e8cee4b4db0f9
|
|
4
|
+
data.tar.gz: 88056dd48a9c6bfc81853d72c61d1783816921ff546875321de26d2f2241c154
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c07db6a65c33503843c0c39ee17affff523ba946f9bc6993dceacbce4e926f2358bc7424bb2e892ff3732e4848f66a7a9bddd9a78f5ad54f49605cd75d987916
|
|
7
|
+
data.tar.gz: dd0fffe383ce5a329d52a49f6225d325768fcf59a0f3752e27edb460a81e0913bf1a42615c2eea9ddecae723d82f8edbb7657535e7a4d31485e0929728e59671
|
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
|
|
@@ -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_val)
|
|
52
|
+
return 2.0 if m_val <= 0
|
|
53
|
+
pav = model['pav_h']
|
|
54
|
+
return 2.0 if m_val <= pav[0][0]
|
|
55
|
+
return pav[-1][1] if m_val >= pav[-1][0]
|
|
56
|
+
pav.each_with_index do |(_xk, yk), i|
|
|
57
|
+
next unless i + 1 < pav.size
|
|
58
|
+
return yk if m_val <= 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 StandardError => 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=#{qs.gsub(',', '%2C').gsub('|', '%7C')}")
|
|
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,233 @@
|
|
|
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
|
+
adapter.training_samples.each(&block)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
warn "Collector: #{adapter.name} failed: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all_adapters
|
|
35
|
+
Sync::Base.adapters
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve_sources(names)
|
|
39
|
+
names.map do |name|
|
|
40
|
+
Sync::Base.adapters.find { |a| a.name.demodulize.downcase == name.to_s.downcase }
|
|
41
|
+
end.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def evaluate_sample(sample)
|
|
45
|
+
altitudes = sample[:altitudes]
|
|
46
|
+
distances = sample[:distances]
|
|
47
|
+
locations = sample[:locations]
|
|
48
|
+
source = sample[:source]
|
|
49
|
+
id = sample[:id]
|
|
50
|
+
|
|
51
|
+
return nil if altitudes.size < 2
|
|
52
|
+
return nil if distances.empty? || distances.last < 1000
|
|
53
|
+
return nil if sample[:altitudes].size < 2
|
|
54
|
+
|
|
55
|
+
dem = TerrainCoeff.elevations_for(locations)
|
|
56
|
+
return nil if dem.empty? || dem.compact.size < 2
|
|
57
|
+
|
|
58
|
+
truth_min = dem.compact.min
|
|
59
|
+
truth_max = dem.compact.max
|
|
60
|
+
sm = smooth(altitudes)
|
|
61
|
+
|
|
62
|
+
best = nil
|
|
63
|
+
(2.0..3.0).step(0.1).each do |h|
|
|
64
|
+
h = h.round(2)
|
|
65
|
+
segs = segments(sm, h)
|
|
66
|
+
chosen = nil
|
|
67
|
+
SPACINGS.each do |sp|
|
|
68
|
+
sel = sample_indices(sm, segs, distances, sp)
|
|
69
|
+
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
70
|
+
err = [(lmin - truth_min).abs, (lmax - truth_max).abs].max
|
|
71
|
+
if err <= BUDGET
|
|
72
|
+
chosen = { h: h, spacing: sp, err: err, npts: npts }
|
|
73
|
+
break
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
unless chosen
|
|
77
|
+
sel = sample_indices(sm, segs, distances, SPACINGS.last)
|
|
78
|
+
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
79
|
+
err = [(lmin - truth_min).abs, (lmax - truth_max).abs].max
|
|
80
|
+
chosen = { h: h, spacing: SPACINGS.last, err: err, npts: npts }
|
|
81
|
+
end
|
|
82
|
+
best = chosen if best.nil? || chosen[:npts] < best[:npts]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
return nil unless best
|
|
86
|
+
|
|
87
|
+
f = features(altitudes, distances, locations)
|
|
88
|
+
{
|
|
89
|
+
id: id,
|
|
90
|
+
source: source,
|
|
91
|
+
truth_min: truth_min,
|
|
92
|
+
truth_max: truth_max,
|
|
93
|
+
h_opt: best[:h],
|
|
94
|
+
spacing_opt: best[:spacing],
|
|
95
|
+
err: best[:err].round(2),
|
|
96
|
+
npts: best[:npts],
|
|
97
|
+
gain_per_km: f[:gain_per_km],
|
|
98
|
+
range_per_km: f[:range_per_km],
|
|
99
|
+
seg_density: f[:seg_density],
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def features(altitudes, distances, _locations)
|
|
104
|
+
return nil if altitudes.empty? || distances.empty?
|
|
105
|
+
|
|
106
|
+
dm = distances.last
|
|
107
|
+
km = [dm / 1000.0, 1e-6].max
|
|
108
|
+
gain = elevation_gain(altitudes)
|
|
109
|
+
range = altitudes.empty? ? 0.0 : (altitudes.max - altitudes.min)
|
|
110
|
+
gain_per_km = gain / km
|
|
111
|
+
range_per_km = range / km
|
|
112
|
+
seg_density = segments(smooth(altitudes), 2.5).size / km
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
gain_per_km: gain_per_km,
|
|
116
|
+
range_per_km: range_per_km,
|
|
117
|
+
seg_density: seg_density,
|
|
118
|
+
distance_m: dm,
|
|
119
|
+
npts: altitudes.size,
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def elevation_gain(alts)
|
|
124
|
+
total = 0.0
|
|
125
|
+
(1...alts.size).each do |i|
|
|
126
|
+
diff = alts[i] - alts[i - 1]
|
|
127
|
+
total += diff if diff > 0
|
|
128
|
+
end
|
|
129
|
+
total
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def smooth(vals)
|
|
133
|
+
moving_avg(moving_median(vals, 9), 9)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def moving_median(vals, window)
|
|
137
|
+
half = window / 2
|
|
138
|
+
(0...vals.size).map do |i|
|
|
139
|
+
lo = [0, i - half].max
|
|
140
|
+
hi = [vals.size - 1, i + half].min
|
|
141
|
+
vals[lo..hi].sort[(hi - lo) / 2]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def moving_avg(vals, window)
|
|
146
|
+
half = window / 2
|
|
147
|
+
(0...vals.size).map do |i|
|
|
148
|
+
lo = [0, i - half].max
|
|
149
|
+
hi = [vals.size - 1, i + half].min
|
|
150
|
+
vals[lo..hi].sum.to_f / (hi - lo + 1)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def segments(smooth, threshold, min_len: 20)
|
|
155
|
+
n = smooth.size
|
|
156
|
+
return [[0, n - 1]] if n < 2
|
|
157
|
+
|
|
158
|
+
segs = []
|
|
159
|
+
seg_start = 0
|
|
160
|
+
ext_val = smooth[0]
|
|
161
|
+
ext_idx = 0
|
|
162
|
+
rising = nil
|
|
163
|
+
(1...n).each do |i|
|
|
164
|
+
v = smooth[i]
|
|
165
|
+
if rising.nil?
|
|
166
|
+
if v > ext_val
|
|
167
|
+
ext_val = v
|
|
168
|
+
ext_idx = i
|
|
169
|
+
rising = true
|
|
170
|
+
elsif v < ext_val
|
|
171
|
+
ext_val = v
|
|
172
|
+
ext_idx = i
|
|
173
|
+
rising = false
|
|
174
|
+
end
|
|
175
|
+
elsif rising
|
|
176
|
+
if v > ext_val
|
|
177
|
+
ext_val = v
|
|
178
|
+
ext_idx = i
|
|
179
|
+
elsif ext_val - v >= threshold
|
|
180
|
+
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
181
|
+
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
182
|
+
rising = false
|
|
183
|
+
ext_val = v
|
|
184
|
+
ext_idx = i
|
|
185
|
+
end
|
|
186
|
+
elsif v < ext_val
|
|
187
|
+
ext_val = v
|
|
188
|
+
ext_idx = i
|
|
189
|
+
elsif v - ext_val >= threshold
|
|
190
|
+
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
191
|
+
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
192
|
+
rising = true
|
|
193
|
+
ext_val = v
|
|
194
|
+
ext_idx = i
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
segs << [seg_start, n - 1] if n - 1 - seg_start >= min_len
|
|
198
|
+
segs.empty? ? [[0, n - 1]] : segs
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def sample_indices(smooth, segs, dist, spacing)
|
|
202
|
+
sel = Set.new
|
|
203
|
+
segs.each do |a, b|
|
|
204
|
+
sel << a
|
|
205
|
+
sel << b
|
|
206
|
+
next if b - a < 2
|
|
207
|
+
|
|
208
|
+
d0 = dist[a].to_f
|
|
209
|
+
d1 = dist[b].to_f
|
|
210
|
+
len = d1 - d0
|
|
211
|
+
seg_range = smooth[a..b].max - smooth[a..b].min
|
|
212
|
+
next unless len > 500 && seg_range < 3.0
|
|
213
|
+
|
|
214
|
+
k = 1
|
|
215
|
+
while d0 + k * spacing < d1
|
|
216
|
+
target = d0 + k * spacing
|
|
217
|
+
idx = a + ((b - a) * (target - d0) / len).round
|
|
218
|
+
sel << idx
|
|
219
|
+
k += 1
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
sel.to_a.sort
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def layered_estimate(dem, sel)
|
|
226
|
+
vals = sel.map { |i| dem[i] }.compact
|
|
227
|
+
return [nil, nil, 0] if vals.empty?
|
|
228
|
+
[vals.min, vals.max, vals.size]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
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
|
|
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
|
@@ -122,6 +122,61 @@ module GitFit
|
|
|
122
122
|
def camelcase_to_snakecase(str)
|
|
123
123
|
str.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
124
124
|
end
|
|
125
|
+
|
|
126
|
+
def training_samples
|
|
127
|
+
std = File.join('data', 'std', config_key)
|
|
128
|
+
return [] unless Dir.exist?(std)
|
|
129
|
+
|
|
130
|
+
Dir.children(std).filter_map do |f|
|
|
131
|
+
next unless f.end_with?('.json')
|
|
132
|
+
|
|
133
|
+
pts = JSON.parse(File.read(File.join(std, f))) rescue next
|
|
134
|
+
next unless pts.is_a?(Array) && pts.any?
|
|
135
|
+
|
|
136
|
+
altitudes = pts.filter_map { |p| p['altitude']&.to_f }
|
|
137
|
+
next if altitudes.size < 2
|
|
138
|
+
|
|
139
|
+
locations = pts.filter_map { |p| extract_location(p) }
|
|
140
|
+
next if locations.size < 2
|
|
141
|
+
|
|
142
|
+
distances = cumulative_distances(locations)
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
altitudes: altitudes,
|
|
146
|
+
distances: distances,
|
|
147
|
+
locations: locations,
|
|
148
|
+
source: config_key,
|
|
149
|
+
id: File.basename(f, '.json'),
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_location(point)
|
|
155
|
+
lat = point['positionLat'] || point['latitude'] || point['lat']
|
|
156
|
+
lng = point['positionLong'] || point['longitude'] || point['lng']
|
|
157
|
+
return nil unless lat && lng
|
|
158
|
+
[lng.to_f, lat.to_f]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def cumulative_distances(locations)
|
|
162
|
+
return [] if locations.size < 2
|
|
163
|
+
|
|
164
|
+
dists = [0.0]
|
|
165
|
+
(1...locations.size).each do |i|
|
|
166
|
+
dists << dists.last + haversine(locations[i - 1], locations[i])
|
|
167
|
+
end
|
|
168
|
+
dists
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def haversine(loc1, loc2)
|
|
172
|
+
r = 6_371_000.0
|
|
173
|
+
dlat = (loc2[0] - loc1[0]) * Math::PI / 180
|
|
174
|
+
dlon = (loc2[1] - loc1[1]) * Math::PI / 180
|
|
175
|
+
la1 = loc1[0] * Math::PI / 180
|
|
176
|
+
la2 = loc2[0] * Math::PI / 180
|
|
177
|
+
h = Math.sin(dlat / 2)**2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dlon / 2)**2
|
|
178
|
+
2 * r * Math.asin(Math.sqrt(h))
|
|
179
|
+
end
|
|
125
180
|
end
|
|
126
181
|
|
|
127
182
|
protected
|
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.1
|
|
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
|