git-fit 0.20.0 → 0.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c268d464a4e366bef89848504d3a6b9f3c4dd47139f1ab9d8051b869ae38f00
4
- data.tar.gz: 25060c54bb7135b5400ac03b3f7b310760e1a2ccba681e32b2780be59f976ae7
3
+ metadata.gz: 30f139ea0a7c21e9a99189ac7541c8b80c35377f40742823843dbcd5b2ce604a
4
+ data.tar.gz: b554587d97956af6e3a532ab67b6eca755377005f82eec38c500414d7bd1c4c4
5
5
  SHA512:
6
- metadata.gz: 41445db3b930293a97320e977e3b5e3338ab402722ab50ddba938af79a1bf9b1504f42547d5e5ca90811bd86c76e9a32289470b39c302e25546bc7edf0dd45f3
7
- data.tar.gz: 7fa3934d598cb97e7061157c812404858033d0f96460b9022944a9e6f604c3188c2ffb22b1b9933c7653785c9d47a8d1daf04c5c497e579fb24cc31652b8f069
6
+ metadata.gz: 5b23770d805a5c42fdbe3b4322e3557bd4938033e2e7e3094e934354d16fbc8c6bb7d7ed028ae5ffe9684b785d7e5fdca560e17480fd020713c1b96302795bb2
7
+ data.tar.gz: 32dde756e5dc46d3f859a992779187ccf2e478e9de05b681f79184ba2c35838822f29586f9d4e9c79d26e1d8d767c1771d4ed80172fa6fd21ec9ff7851345720
@@ -0,0 +1,19 @@
1
+ Sequel.migration do
2
+ up do
3
+ alter_table(:activities) do
4
+ add_column :elevation_gain_terrain, Float
5
+ add_column :elevation_loss_terrain, Float
6
+ add_column :elevation_min_terrain, Float
7
+ add_column :elevation_max_terrain, Float
8
+ end
9
+ end
10
+
11
+ down do
12
+ alter_table(:activities) do
13
+ drop_column :elevation_max_terrain
14
+ drop_column :elevation_min_terrain
15
+ drop_column :elevation_loss_terrain
16
+ drop_column :elevation_gain_terrain
17
+ end
18
+ end
19
+ end
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/correct'
98
99
  require_relative 'git_fit/elevation/terrain_coeff'
99
100
  require_relative 'git_fit/cli/strava'
100
101
  require_relative 'git_fit/auth/garmin_token'
@@ -11,7 +11,7 @@ module GitFit
11
11
  end
12
12
  end
13
13
 
14
- desc 'backfill [SOURCE]', 'Backfill elevation data (gain/loss/min/max) from raw FIT files or L2 data'
14
+ desc 'backfill [SOURCE]', 'Backfill raw elevation data (device/API values) from FIT files or L2 data'
15
15
  option :force, type: :boolean, default: false, desc: 'Overwrite existing values (default: only fill NULLs)'
16
16
  def backfill(source = nil)
17
17
  config = git_fit_config
@@ -28,7 +28,33 @@ module GitFit
28
28
  say_status :error, "Elevation backfill failed: #{e.message}", :red
29
29
  end
30
30
 
31
- desc 'train', 'Train DEM terrain coefficient model from activity data'
31
+ desc 'correct [SOURCE]', 'Apply elevation correction into _terrain columns (adaptive hysteresis; --dem adds DEM)'
32
+ option :force, type: :boolean, default: false, desc: 'Recompute even when _terrain columns are already populated'
33
+ option :'dry-run', type: :boolean, default: false, desc: 'Count records without writing to database'
34
+ option :dem, type: :boolean, default: true,
35
+ desc: 'Enable DEM correction (default: true; --no-dem for hysteresis only)'
36
+ def correct(source = nil)
37
+ config = git_fit_config
38
+ conn = GitFit::DB::Connection.new(config.db_path)
39
+ conn.migrate!
40
+ db = conn.db
41
+
42
+ result = GitFit::Elevation::Correct.new(
43
+ db,
44
+ force: options[:force],
45
+ dry_run: options[:'dry-run'],
46
+ dem: options[:dem],
47
+ ).call(source)
48
+
49
+ msg = "Elevation correct: #{result[:updated]} updated, " \
50
+ "#{result[:skipped]} skipped, #{result[:failed]} failed, " \
51
+ "#{result[:no_model]} no_model"
52
+ say_status :done, msg, :green
53
+ rescue StandardError => e
54
+ say_status :error, "Elevation correct failed: #{e.message}", :red
55
+ end
56
+
57
+ desc 'train', 'Train DEM terrain coefficient model from activity data', hide: true
32
58
  option :sources, type: :string, default: nil, desc: 'Comma-separated source adapters (default: all)'
33
59
  option :output, type: :string, default: nil, desc: 'Output path for model file'
34
60
  def train
@@ -51,7 +77,7 @@ module GitFit
51
77
  exit 1
52
78
  end
53
79
 
54
- desc 'infer GAIN_PER_KM RANGE_PER_KM SEG_DENSITY', 'Infer optimal h threshold for terrain'
80
+ desc 'infer GAIN_PER_KM RANGE_PER_KM SEG_DENSITY', 'Infer optimal h threshold for terrain', hide: true
55
81
  def infer(gain_per_km, range_per_km, seg_density)
56
82
  g = gain_per_km.to_f
57
83
  r = range_per_km.to_f
@@ -77,7 +103,7 @@ module GitFit
77
103
  exit 1
78
104
  end
79
105
 
80
- desc 'model-info', 'Show information about the loaded DEM model'
106
+ desc 'model-info', 'Show information about the loaded DEM model', hide: true
81
107
  def model_info
82
108
  model = GitFit::Elevation::TerrainCoeff.model
83
109
  path = GitFit::Elevation::TerrainCoeff.model_path
@@ -5,8 +5,6 @@ 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
10
8
 
11
9
  def initialize(db, force: false)
12
10
  @db = db
@@ -88,8 +86,8 @@ module GitFit
88
86
  nil
89
87
  end
90
88
 
91
- gain = session&.dig('totalAscent').is_a?(Numeric) ? session['totalAscent'] : elevation_gain_from_alts(alts)
92
- loss = session&.dig('totalDescent').is_a?(Numeric) ? session['totalDescent'] : elevation_loss_from_alts(alts)
89
+ gain = session&.dig('totalAscent').is_a?(Numeric) ? session['totalAscent'] : nil
90
+ loss = session&.dig('totalDescent').is_a?(Numeric) ? session['totalDescent'] : nil
93
91
 
94
92
  {
95
93
  elevation_gain: gain&.round(1),
@@ -102,22 +100,7 @@ module GitFit
102
100
  nil
103
101
  end
104
102
 
105
- def compute_from_l2(source, natural_id)
106
- l2_path = File.join('data', 'std', source, "#{natural_id}.json")
107
- return nil unless File.exist?(l2_path)
108
-
109
- l2 = JSON.parse(File.read(l2_path))
110
- alts = l2.filter_map { |pt| pt['altitude'] || pt[:altitude] }
111
- return nil if alts.size < 2
112
-
113
- {
114
- elevation_gain: elevation_gain_from_alts(alts)&.round(1),
115
- elevation_loss: elevation_loss_from_alts(alts)&.round(1),
116
- elevation_min: alts.min&.round(1),
117
- elevation_max: alts.max&.round(1),
118
- }
119
- rescue StandardError => e
120
- warn "Elevation backfill [#{source}][#{natural_id}]: #{e.message}"
103
+ def compute_from_l2(_source, _natural_id)
121
104
  nil
122
105
  end
123
106
 
@@ -141,8 +124,8 @@ module GitFit
141
124
  dl = detail && detail['elevation_loss']
142
125
  dm = detail && detail['max_altitude']
143
126
 
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)
127
+ gain = detail_gain?(dg) ? dg : nil
128
+ loss = detail_gain?(dl) ? dl : nil
146
129
 
147
130
  # max: detail max_altitude is reliable (matches stream max in samples); else raw max.
148
131
  # min: stream is per-track baro-offset, so only drop low spikes, keep the bulk.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module Elevation
5
+ # Strategy-corrected elevation: L2 std → (optional DEM) → adaptive hysteresis
6
+ # threshold → activities.elevation_*_terrain. Raw device/API values stay in
7
+ # elevation_* (written by Backfill); this class only fills the _terrain columns.
8
+ class Correct
9
+ SOURCES = %w[garmin garmin_cn strava keep igpsport xoss xingzhe 2bulu apple_health].freeze
10
+ BARO_SOURCES = %w[xingzhe 2bulu apple_health].freeze
11
+ BATCH_SIZE = 100
12
+ STATIC_HYST_BARO = 2.0
13
+ STATIC_HYST_GPS = 3.0
14
+
15
+ def initialize(db, force: false, dry_run: false, dem: true)
16
+ @db = db
17
+ @force = force
18
+ @dry_run = dry_run
19
+ @dem = dem
20
+ @updated = 0
21
+ @skipped = 0
22
+ @failed = 0
23
+ @no_model = 0
24
+ end
25
+
26
+ def call(source = nil)
27
+ sources = source ? [source] : SOURCES
28
+ sources.each do |src|
29
+ correct_source(src)
30
+ end
31
+ { updated: @updated, skipped: @skipped, failed: @failed, no_model: @no_model }
32
+ end
33
+
34
+ private
35
+
36
+ def correct_source(source)
37
+ offset = 0
38
+ loop do
39
+ rows = fetch_rows(source, offset)
40
+ break if rows.empty?
41
+
42
+ @db.transaction do
43
+ rows.each { |row| process_row(row, source) }
44
+ end
45
+
46
+ offset += BATCH_SIZE
47
+ end
48
+ end
49
+
50
+ def fetch_rows(source, offset)
51
+ scope = @db[:activities].where(source: source)
52
+ unless @force
53
+ scope = scope.where(Sequel.|(
54
+ { elevation_gain_terrain: nil },
55
+ { elevation_loss_terrain: nil },
56
+ { elevation_min_terrain: nil },
57
+ { elevation_max_terrain: nil },
58
+ ))
59
+ end
60
+ scope.order(:id).limit(BATCH_SIZE, offset).all
61
+ end
62
+
63
+ def process_row(row, source)
64
+ run_id = row[:run_id]
65
+ natural_id = run_id.sub("#{source}_", '')
66
+
67
+ elev = compute_from_l2(source, natural_id)
68
+ return skip_row(run_id) unless elev
69
+
70
+ update_row(row, elev) unless @dry_run
71
+ @updated += 1
72
+ rescue StandardError => e
73
+ warn "Elevation correct [#{source}][#{natural_id}]: #{e.message}"
74
+ @failed += 1
75
+ end
76
+
77
+ def compute_from_l2(source, natural_id)
78
+ l2_path = File.join('data', 'std', source, "#{natural_id}.json")
79
+ return nil unless File.exist?(l2_path)
80
+
81
+ l2 = JSON.parse(File.read(l2_path))
82
+ return nil unless l2.is_a?(Array)
83
+
84
+ pts = l2.filter_map do |pt|
85
+ next unless pt.is_a?(Hash)
86
+ next unless pt['latitude'] && pt['longitude'] && pt['altitude']
87
+
88
+ pt
89
+ end
90
+ return nil if pts.size < 2
91
+
92
+ locations = pts.map { |pt| [pt['latitude'].to_f, pt['longitude'].to_f] }
93
+ alts_raw = pts.map { |pt| pt['altitude'].to_f }
94
+
95
+ dem_alts = dem_corrected_altitudes(locations, alts_raw)
96
+ h = hysteresis_threshold(source, locations, alts_raw)
97
+
98
+ gain = elevation_gain_from_alts(dem_alts, threshold: h)
99
+ loss = elevation_loss_from_alts(dem_alts, threshold: h)
100
+ filtered = Backfill.filter_low_outliers(dem_alts)
101
+
102
+ {
103
+ elevation_gain_terrain: gain.round(1),
104
+ elevation_loss_terrain: loss.round(1),
105
+ elevation_min_terrain: (filtered.min || dem_alts.min)&.round(1),
106
+ elevation_max_terrain: dem_alts.max&.round(1),
107
+ }
108
+ rescue StandardError => e
109
+ warn "Elevation correct [#{source}][#{natural_id}]: #{e.message}"
110
+ nil
111
+ end
112
+
113
+ def dem_corrected_altitudes(locations, alts_raw)
114
+ return alts_raw unless @dem
115
+
116
+ elevs = TerrainCoeff.elevations_for(locations)
117
+ # Partial DEM batch failures leave nil holes; mixing them with raw
118
+ # altitudes would corrupt gain/loss — fall back wholesale instead.
119
+ return alts_raw if elevs.nil? || elevs.any?(&:nil?)
120
+
121
+ elevs
122
+ rescue StandardError => e
123
+ warn "DEM correction failed: #{e.message}; using raw altitudes"
124
+ @no_model += 1
125
+ alts_raw
126
+ end
127
+
128
+ def hysteresis_threshold(source, locations, alts_raw)
129
+ gain_per_km, range_per_km, seg_density = terrain_features(locations, alts_raw)
130
+ m = TerrainCoeff.terrain_m(
131
+ gain_per_km: gain_per_km,
132
+ range_per_km: range_per_km,
133
+ seg_density: seg_density,
134
+ )
135
+ TerrainCoeff.hysteresis_threshold(m)
136
+ rescue StandardError => e
137
+ warn "adaptive threshold failed: #{e.message}; using static threshold"
138
+ static_threshold(source)
139
+ end
140
+
141
+ def terrain_features(locations, alts)
142
+ distances = Sync::Base.cumulative_distances(locations)
143
+ total_dist_km = (distances.last || 0) / 1000.0
144
+ if total_dist_km.positive?
145
+ gain_per_km = elevation_gain_from_alts(alts, threshold: 0.0) / total_dist_km
146
+ range_per_km = (alts.max - alts.min) / total_dist_km
147
+ seg_density = alts.size / total_dist_km
148
+ end
149
+ [gain_per_km || 0.0, range_per_km || 0.0, seg_density || 0.0]
150
+ end
151
+
152
+ def static_threshold(source)
153
+ BARO_SOURCES.include?(source) ? STATIC_HYST_BARO : STATIC_HYST_GPS
154
+ end
155
+
156
+ def elevation_gain_from_alts(alts, threshold: 0.0)
157
+ return 0.0 if alts.size < 2
158
+
159
+ (1...alts.size).sum do |i|
160
+ d = alts[i] - alts[i - 1]
161
+ d > threshold ? d : 0.0
162
+ end
163
+ end
164
+
165
+ def elevation_loss_from_alts(alts, threshold: 0.0)
166
+ return 0.0 if alts.size < 2
167
+
168
+ (1...alts.size).sum do |i|
169
+ d = alts[i - 1] - alts[i]
170
+ d > threshold ? d : 0.0
171
+ end
172
+ end
173
+
174
+ def update_row(row, elev)
175
+ @db[:activities].where(id: row[:id]).update(
176
+ elevation_gain_terrain: elev[:elevation_gain_terrain],
177
+ elevation_loss_terrain: elev[:elevation_loss_terrain],
178
+ elevation_min_terrain: elev[:elevation_min_terrain],
179
+ elevation_max_terrain: elev[:elevation_max_terrain],
180
+ updated_at: Time.now.utc,
181
+ )
182
+ end
183
+
184
+ def skip_row(_run_id)
185
+ @skipped += 1
186
+ end
187
+ end
188
+ end
189
+ end
@@ -16,6 +16,10 @@ module GitFit
16
16
  GRID = 0.0003
17
17
 
18
18
  class << self
19
+ # OpenTopography free tier allows 1 request/second — pacing below that
20
+ # guarantees a 429 on every cold-cache slice (observed in workouts CI).
21
+ DEM_SLICE_INTERVAL = 1.05
22
+
19
23
  def model_path
20
24
  @model_path ||= find_model
21
25
  end
@@ -62,18 +66,26 @@ module GitFit
62
66
  def elevations_for(locations)
63
67
  uniq_vals = uniquify_locations(locations)
64
68
  elevs = {}
69
+ last_api = nil
65
70
  uniq_vals.each_slice(100).with_index do |slice, bi|
66
71
  cached = DemCache.load_batch(slice)
67
72
  elevs.merge!(cached)
68
73
  missing = DemCache.missing_keys(slice, cached)
69
- if missing.any?
70
- missing_locations = missing.map { |lat, lng| [lat, lng] }
71
- batch = {}
72
- DemFetch.fetch_batch(missing_locations, bi, batch, method(:grid_key))
73
- elevs.merge!(batch)
74
- DemCache.save_batch(batch)
74
+ next if missing.empty?
75
+
76
+ # Pace API→API independently of interleaved cache-hit slices:
77
+ # cache processing is free, only consecutive fetches must respect
78
+ # the provider's 1 req/s limit.
79
+ if last_api
80
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_api
81
+ sleep(DEM_SLICE_INTERVAL - elapsed) if elapsed < DEM_SLICE_INTERVAL
75
82
  end
76
- sleep 0.15
83
+ missing_locations = missing.map { |lat, lng| [lat, lng] }
84
+ batch = {}
85
+ DemFetch.fetch_batch(missing_locations, bi, batch, method(:grid_key))
86
+ elevs.merge!(batch)
87
+ DemCache.save_batch(batch)
88
+ last_api = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
89
  end
78
90
  locations.map { |lat, lng| elevs[grid_key(lat, lng)] }
79
91
  end
@@ -44,8 +44,8 @@ module GitFit
44
44
  cat = r[:sport_category] || 'other'
45
45
  d = r[:distance] || 0
46
46
  t = r[:moving_time] || 0
47
- e = r[:elevation_gain] || 0
48
- l = r[:elevation_loss] || 0
47
+ e = r[:elevation_gain_terrain] || r[:elevation_gain] || 0
48
+ l = r[:elevation_loss_terrain] || r[:elevation_loss] || 0
49
49
  s = r[:steps] || 0
50
50
 
51
51
  total_dist += d
@@ -113,8 +113,8 @@ module GitFit
113
113
  acc[:count] += 1
114
114
  acc[:distance_km] += (row[:distance] || 0) / 1000.0
115
115
  acc[:moving_time_h] += (row[:moving_time] || 0) / 3600.0
116
- acc[:total_elev] += row[:elevation_gain] || 0
117
- acc[:total_loss] += row[:elevation_loss] || 0
116
+ acc[:total_elev] += row[:elevation_gain_terrain] || row[:elevation_gain] || 0
117
+ acc[:total_loss] += row[:elevation_loss_terrain] || row[:elevation_loss] || 0
118
118
  acc[:total_steps] += row[:steps] || 0
119
119
  add_if_positive(acc, :total_hr, :hr_count, row[:average_heartrate])
120
120
  add_if_positive(acc, :total_cadence, :cadence_count, row[:average_cadence])
@@ -167,8 +167,8 @@ module GitFit
167
167
  by_year.sort.map do |year, acts|
168
168
  total_dist = acts.sum { |r| r[:distance] || 0 } / 1000.0
169
169
  total_time = acts.sum { |r| r[:moving_time] || 0 }
170
- total_elev = acts.sum { |r| r[:elevation_gain] || 0 }
171
- total_loss = acts.sum { |r| r[:elevation_loss] || 0 }
170
+ total_elev = acts.sum { |r| r[:elevation_gain_terrain] || r[:elevation_gain] || 0 }
171
+ total_loss = acts.sum { |r| r[:elevation_loss_terrain] || r[:elevation_loss] || 0 }
172
172
  total_steps = acts.sum { |r| r[:steps] || 0 }
173
173
  longest = acts.map { |r| r[:distance] || 0 }.max / 1000.0
174
174
 
@@ -12,6 +12,7 @@ module GitFit
12
12
  average_heartrate max_heartrate average_cadence max_cadence
13
13
  average_power max_power calories average_temperature
14
14
  average_speed elevation_gain elevation_loss elevation_min elevation_max
15
+ elevation_gain_terrain elevation_loss_terrain elevation_min_terrain elevation_max_terrain
15
16
  steps source dedup_group_id
16
17
  ].freeze
17
18
 
@@ -79,10 +79,10 @@ module GitFit
79
79
  calories: a[:calories],
80
80
  average_temperature: a[:average_temperature],
81
81
  average_speed: a[:average_speed],
82
- elevation_gain: a[:elevation_gain],
83
- elevation_loss: a[:elevation_loss],
84
- elevation_min: a[:elevation_min],
85
- elevation_max: a[:elevation_max],
82
+ elevation_gain: a[:elevation_gain_terrain] || a[:elevation_gain],
83
+ elevation_loss: a[:elevation_loss_terrain] || a[:elevation_loss],
84
+ elevation_min: a[:elevation_min_terrain] || a[:elevation_min],
85
+ elevation_max: a[:elevation_max_terrain] || a[:elevation_max],
86
86
  steps: a[:steps],
87
87
  source: a[:source],
88
88
  }
@@ -109,7 +109,9 @@ module GitFit
109
109
  composite = members.first&.slice(:run_id, :distance, :moving_time, :elapsed_time,
110
110
  :average_heartrate, :max_heartrate, :elevation_gain, :elevation_loss,
111
111
  :elevation_min, :elevation_max, :steps,
112
- :average_speed, :average_cadence, :max_cadence) || {}
112
+ :average_speed, :average_cadence, :max_cadence,
113
+ :elevation_gain_terrain, :elevation_loss_terrain,
114
+ :elevation_min_terrain, :elevation_max_terrain) || {}
113
115
  composite[:summary_polyline] = pick_best_polyline(group_activities)
114
116
  composite
115
117
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.20.0'
4
+ VERSION = '0.21.1'
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.20.0
4
+ version: 0.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -259,6 +259,7 @@ files:
259
259
  - db/migrations/001_full_schema.rb
260
260
  - db/migrations/002_iso8601_time_format.rb
261
261
  - db/migrations/003_add_elevation_fields.rb
262
+ - db/migrations/004_add_elevation_terrain_fields.rb
262
263
  - exe/git-fit
263
264
  - lib/git-fit.rb
264
265
  - lib/git_fit/auth/garmin.rb
@@ -293,6 +294,7 @@ files:
293
294
  - lib/git_fit/dedup/service.rb
294
295
  - lib/git_fit/dedup/trajectory.rb
295
296
  - lib/git_fit/elevation/backfill.rb
297
+ - lib/git_fit/elevation/correct.rb
296
298
  - lib/git_fit/elevation/data/dem_coeff.json
297
299
  - lib/git_fit/elevation/data/dem_lookup.json
298
300
  - lib/git_fit/elevation/dem_cache.rb