git-fit 0.18.2 → 0.18.4

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: 41afe213d0cfd11467217bc1fca559a90d791d08131f4ad2a8b1be69655cdfbe
4
- data.tar.gz: bd97306411a2b55af3a0253646cbd43aff7907d4253175ea88372cea457f7e0c
3
+ metadata.gz: b68548ae0905b7374d85979f27053683d604ab26c8ae0e66011cd6cf776bf7ad
4
+ data.tar.gz: 863da16427ca424985a8f6ef62237b9a53e2c782fc390950323fd04ad2cb6929
5
5
  SHA512:
6
- metadata.gz: eee9145cb899365d6cc669dcc065e6df9d9785ce97522c3e0f561f40225193400f664158b7335395d655b4c5a4d927e835098a39a1a4cb686ff38f4deac455e0
7
- data.tar.gz: 748ed231a6b7ac6fe293e6c5481da6207cf87472645214019c468ed9f95628f2943bd955980df2e4d56456af6f7dae7891c53b0d67159d9ebb799c20b6e2dbeb
6
+ metadata.gz: fa7450b9a823e2b8dca5d5a81d6663caba96fdc9f43444cb53619b5f0cf3b4afc2016b3e69c74e03a4a53250cd4f6a7f79b38862a87a98dc1fb4a538f014117f
7
+ data.tar.gz: 1d7a706ea4b63ccf6f2607a236d73bbbcdeee9a49fb809846030667f8dec6b2b6f43eec3019891d00fbcb9d8b71198c3eec72243bc4a0d9e650bb012dfaff481
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module GitFit
7
+ module Elevation
8
+ module DemCache
9
+ DEM_CACHE_DIR = ENV.fetch('GIT_FIT_DEM_CACHE_DIR', 'data/cache/dem')
10
+ CACHE_FILE = File.join(DEM_CACHE_DIR, 'dem.json')
11
+ GRID = 0.0003
12
+
13
+ class << self
14
+ def grid_key(lat, lng)
15
+ "#{(lat / GRID).round}/#{(lng / GRID).round}"
16
+ end
17
+
18
+ def load_all
19
+ return {} unless File.exist?(CACHE_FILE)
20
+ JSON.parse(File.read(CACHE_FILE))
21
+ rescue StandardError
22
+ {}
23
+ end
24
+
25
+ def load_batch(slice)
26
+ all = load_all
27
+ results = {}
28
+ slice.each do |lat, lng|
29
+ gk = grid_key(lat, lng)
30
+ results[gk] = all[gk] if all.key?(gk)
31
+ end
32
+ results
33
+ end
34
+
35
+ def missing_keys(slice, cached)
36
+ slice.reject { |lat, lng| cached.key?(grid_key(lat, lng)) }
37
+ end
38
+
39
+ def save_batch(elevs_by_gk)
40
+ return if elevs_by_gk.empty?
41
+ FileUtils.mkdir_p(DEM_CACHE_DIR)
42
+ existing = load_all
43
+ elevs_by_gk.each { |gk, elev| existing[gk] = elev }
44
+ File.write(CACHE_FILE, JSON.generate(existing))
45
+ rescue StandardError => e
46
+ warn "DEM cache write error: #{e.message}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module GitFit
7
+ module Elevation
8
+ module DemFetch
9
+ DEM_URL = 'https://api.opentopodata.org/v1/srtm30m'
10
+
11
+ class << self
12
+ def fetch_batch(slice, batch_idx, elevs, grid_key_fn)
13
+ qs = slice.map { |la, ln| "#{la},#{ln}" }.join('|')
14
+ uri = URI("#{DEM_URL}?locations=#{qs.gsub(',', '%2C').gsub('|', '%7C')}")
15
+ attempt = 0
16
+ loop do
17
+ res = Net::HTTP.get_response(uri)
18
+ if res.code == '200'
19
+ results = JSON.parse(res.body)['results']
20
+ slice.each_with_index do |(lat, lng), i|
21
+ elevs[grid_key_fn.call(lat, lng)] = results[i]['elevation'] if results[i]
22
+ end
23
+ break
24
+ elsif res.code == '429'
25
+ attempt += 1
26
+ if attempt < 5
27
+ delay = 2**attempt
28
+ warn "DEM batch #{batch_idx}: rate limited, retrying in #{delay}s (attempt #{attempt})"
29
+ sleep(delay)
30
+ else
31
+ warn "DEM batch #{batch_idx}: rate limited, giving up after #{attempt} attempts"
32
+ break
33
+ end
34
+ else
35
+ warn "DEM batch #{batch_idx} HTTP #{res.code}"
36
+ break
37
+ end
38
+ rescue StandardError => e
39
+ warn "DEM batch #{batch_idx}: #{e.message}"
40
+ break
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'msgpack'
4
- require 'net/http'
5
- require 'uri'
4
+
5
+ require_relative 'dem_cache'
6
+ require_relative 'dem_fetch'
7
+ require_relative 'terrain_lookup'
6
8
 
7
9
  module GitFit
8
10
  module Elevation
9
11
  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
+ MODEL_CACHE_DIR = ENV.fetch('GIT_FIT_DEM_MODEL_DIR', 'data/cache/dem_fit')
12
13
  BUNDLED_MODEL_DIR = File.expand_path('model', __dir__)
13
14
  BUNDLED_MODEL = File.join(BUNDLED_MODEL_DIR, 'dem_model.msgpack')
14
15
  MODEL_FILE = 'dem_model.msgpack'
15
- DEM_URL = 'https://api.opentopodata.org/v1/srtm30m'
16
16
  GRID = 0.0003
17
17
 
18
18
  class << self
@@ -25,10 +25,10 @@ module GitFit
25
25
  end
26
26
 
27
27
  def find_model
28
- cache_path = File.join(CACHE_DIR, MODEL_FILE)
28
+ cache_path = File.join(MODEL_CACHE_DIR, MODEL_FILE)
29
29
  return cache_path if File.exist?(cache_path)
30
30
  return BUNDLED_MODEL if File.exist?(BUNDLED_MODEL)
31
- raise "No #{MODEL_FILE} found in cache (#{CACHE_DIR}) or bundled (#{BUNDLED_MODEL})"
31
+ raise "No #{MODEL_FILE} found in cache (#{MODEL_CACHE_DIR}) or bundled (#{BUNDLED_MODEL})"
32
32
  end
33
33
 
34
34
  def reset!
@@ -49,52 +49,41 @@ module GitFit
49
49
  end
50
50
 
51
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]
52
+ Lookup.hysteresis_threshold(model['pav_h'], m_val)
61
53
  end
62
54
 
63
55
  def dem_spacing(gain_per_km:, range_per_km:, seg_density:)
64
- nearest_neighbor_spacing(gain_per_km, range_per_km, seg_density)
56
+ Lookup.nearest_neighbor_spacing(lookup, gain_per_km, range_per_km, seg_density)
65
57
  rescue StandardError => e
66
58
  warn "TerrainCoeff: dem_spacing fallback to 181m (#{e.message})"
67
59
  181
68
60
  end
69
61
 
70
62
  def elevations_for(locations)
71
- uniq = {}
72
- locations.each { |lat, lng| uniq[grid_key(lat, lng)] ||= [lat, lng] }
73
- uniq_vals = uniq.values
74
-
63
+ uniq_vals = uniquify_locations(locations)
75
64
  elevs = {}
76
65
  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}"
66
+ cached = DemCache.load_batch(slice)
67
+ elevs.merge!(cached)
68
+ 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)
91
75
  end
92
76
  sleep 0.15
93
77
  end
94
-
95
78
  locations.map { |lat, lng| elevs[grid_key(lat, lng)] }
96
79
  end
97
80
 
81
+ def uniquify_locations(locations)
82
+ uniq = {}
83
+ locations.each { |lat, lng| uniq[grid_key(lat, lng)] ||= [lat, lng] }
84
+ uniq.values
85
+ end
86
+
98
87
  def grid_key(lat, lng)
99
88
  "#{(lat / GRID).round}/#{(lng / GRID).round}"
100
89
  end
@@ -102,24 +91,6 @@ module GitFit
102
91
  def lookup
103
92
  model['lookup']
104
93
  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
94
  end
124
95
  end
125
96
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module Elevation
5
+ module TerrainCoeff
6
+ module Lookup
7
+ class << self
8
+ def nearest_neighbor_spacing(model_lookup, gain_per_km, range_per_km, seg_density)
9
+ best = nil
10
+ best_dist = Float::INFINITY
11
+ model_lookup.each do |ref|
12
+ d = Math.sqrt(
13
+ (gain_per_km.to_f - ref['gk'])**2 +
14
+ (range_per_km.to_f - ref['rk'])**2 +
15
+ (seg_density.to_f - ref['sd'])**2,
16
+ )
17
+ next unless d < best_dist
18
+ best_dist = d
19
+ best = ref
20
+ end
21
+ best ? best['sp'] : 181
22
+ end
23
+
24
+ def hysteresis_threshold(model_pav_h, m_val)
25
+ return 2.0 if m_val <= 0
26
+ return 2.0 if m_val <= model_pav_h[0][0]
27
+ return model_pav_h[-1][1] if m_val >= model_pav_h[-1][0]
28
+ model_pav_h.each_with_index do |(_xk, yk), i|
29
+ next unless i + 1 < model_pav_h.size
30
+ return yk if m_val <= model_pav_h[i + 1][0]
31
+ end
32
+ model_pav_h[-1][1]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../dem_cache'
3
4
  require_relative '../terrain_coeff'
4
5
 
5
6
  module GitFit
@@ -11,15 +12,25 @@ module GitFit
11
12
 
12
13
  def initialize(sources: nil)
13
14
  @sources = sources
15
+ @samples = []
14
16
  end
15
17
 
16
- def collect
17
- samples = []
18
+ attr_reader :samples
19
+
20
+ def count_samples
21
+ total = 0
22
+ each_source_sample do |_sample|
23
+ total += 1
24
+ end
25
+ total
26
+ end
27
+
28
+ def each_sample
18
29
  each_source_sample do |sample|
19
30
  result = evaluate_sample(sample)
20
- samples << result if result
31
+ @samples << result if result
32
+ yield result
21
33
  end
22
- samples
23
34
  end
24
35
 
25
36
  def each_source_sample(&block)
@@ -37,7 +48,7 @@ module GitFit
37
48
 
38
49
  def resolve_sources(names)
39
50
  names.map do |name|
40
- Sync::Base.adapters.find { |a| a.name.demodulize.downcase == name.to_s.downcase }
51
+ Sync::Base.adapters.find { |a| a.config_key == name.to_s }
41
52
  end.compact
42
53
  end
43
54
 
@@ -50,11 +61,13 @@ module GitFit
50
61
 
51
62
  return nil if altitudes.size < 2
52
63
  return nil if distances.empty? || distances.last < 1000
53
- return nil if sample[:altitudes].size < 2
54
64
 
65
+ cache_before = DemCache.load_batch(locations)
55
66
  dem = TerrainCoeff.elevations_for(locations)
56
67
  return nil if dem.empty? || dem.compact.size < 2
57
68
 
69
+ cache_hit = !cache_before.empty? && cache_before.values.all? { |v| !v.nil? }
70
+
58
71
  truth_min = dem.compact.min
59
72
  truth_max = dem.compact.max
60
73
  sm = smooth(altitudes)
@@ -97,6 +110,7 @@ module GitFit
97
110
  gain_per_km: f[:gain_per_km],
98
111
  range_per_km: f[:range_per_km],
99
112
  seg_density: f[:seg_density],
113
+ cache_hit: cache_hit,
100
114
  }
101
115
  end
102
116
 
@@ -17,19 +17,39 @@ module GitFit
17
17
  end
18
18
 
19
19
  def run
20
- puts 'Collecting samples...'
21
20
  collector = Collector.new(sources: @sources)
22
- samples = collector.collect
21
+ total = collector.count_samples
22
+ puts "Collecting samples (#{total} total)..."
23
23
 
24
- if samples.empty?
25
- warn 'No samples collected'
26
- return false
24
+ collected = 0
25
+ cached = 0
26
+ missed = 0
27
+ skipped = 0
28
+
29
+ collector.each_sample do |result|
30
+ if result
31
+ collected += 1
32
+ if result[:cache_hit]
33
+ cached += 1
34
+ else
35
+ missed += 1
36
+ end
37
+ print_progress(collected, cached, missed, total)
38
+ else
39
+ skipped += 1
40
+ end
27
41
  end
28
42
 
29
- puts "Collected #{samples.size} samples"
43
+ puts
44
+ puts "Collected: #{collected} samples (#{cached} hit / #{missed} miss / #{skipped} skipped)"
45
+
46
+ if collected.zero?
47
+ warn 'No valid samples collected'
48
+ return false
49
+ end
30
50
 
31
51
  puts 'Fitting model...'
32
- fitter = Fitter.new(samples)
52
+ fitter = Fitter.new(collector.samples)
33
53
  model = fitter.fit
34
54
 
35
55
  unless model
@@ -43,15 +63,32 @@ module GitFit
43
63
  return false
44
64
  end
45
65
 
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}"
66
+ print_summary(model)
51
67
 
52
68
  true
53
69
  end
54
70
 
71
+ def print_progress(collected, cached, missed, total)
72
+ pct = (collected.to_f / total * 100).round(1)
73
+ bar_len = 30
74
+ filled = (collected.to_f / total * bar_len).round
75
+ bar = '=' * filled + '-' * (bar_len - filled)
76
+ $stdout.write "\r [#{bar}] #{pct}% #{collected}/#{total} (h:#{cached} m:#{missed})"
77
+ $stdout.flush
78
+ end
79
+
80
+ def print_summary(model)
81
+ meta = model[:meta]
82
+ puts
83
+ puts '=== Model Trained ==='
84
+ puts " samples: #{meta['n_samples']}"
85
+ puts " sources: #{meta['sources'].join(', ')}"
86
+ puts " trained_at: #{meta['trained_at']}"
87
+ puts " ridge_h: #{model[:ridge_h].map { |w| format('%.3f', w) }.join(', ')}"
88
+ puts " norms: #{model[:norms].map { |n| format('%.2f', n) }.join(', ')}"
89
+ puts " output: #{@output}"
90
+ end
91
+
55
92
  def default_output_path
56
93
  File.join(CACHE_DIR, MODEL_FILE)
57
94
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.18.2'
4
+ VERSION = '0.18.4'
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.18.2
4
+ version: 0.18.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -295,7 +295,10 @@ files:
295
295
  - lib/git_fit/elevation/backfill.rb
296
296
  - lib/git_fit/elevation/data/dem_coeff.json
297
297
  - lib/git_fit/elevation/data/dem_lookup.json
298
+ - lib/git_fit/elevation/dem_cache.rb
299
+ - lib/git_fit/elevation/dem_fetch.rb
298
300
  - lib/git_fit/elevation/terrain_coeff.rb
301
+ - lib/git_fit/elevation/terrain_lookup.rb
299
302
  - lib/git_fit/elevation/train/collector.rb
300
303
  - lib/git_fit/elevation/train/fitter.rb
301
304
  - lib/git_fit/elevation/train/runner.rb