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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63105e40b78dfc16931efa9ffe2b88e9ac53b576f2ce14857401d818d6375c5a
4
- data.tar.gz: 792778796644fd2117a9e57b1cb330b2a337ac02010f4297e170639f64b9fa4b
3
+ metadata.gz: f1036c14a867417be89ed1c100aa71de434f38695c49540ef56bf2b7e7d197f9
4
+ data.tar.gz: 66b405c18d393b552ac0e1c0000b92a66f695c6939432af2228f7a4230fd38fe
5
5
  SHA512:
6
- metadata.gz: e6787fb194f869eb5dd48226b617e2d9757cbfb9ee6cfc657018f9e0f868f3018bb66b3a63718ef9d919836298ce0f3e612504bf77fc033a2286dcaaf7c9dd35
7
- data.tar.gz: 1ef52c06c6a9ff330e7e22dd0bb89152bcbeeb861b430a04ffa1841e8e0280ff6e859e2b12007090784e0e692be2fa0b6a6488b1eae825ca44130a3de2d87a5b
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: elevation_gain_from_alts(alts)&.round(1),
137
- elevation_loss: elevation_loss_from_alts(alts)&.round(1),
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 > 0 ? d : 0.0
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 > 0 ? d : 0.0
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
@@ -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)
@@ -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.any?
356
- v = alt.compact.min
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.17.8'
4
+ VERSION = '0.18.0'
5
5
  end
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.17.8
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