git-fit 0.18.3 → 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: 4885ddbb01fd4680fac6ae16c6fd3c1b90092910abcef29b317cacd351ee3c1a
4
- data.tar.gz: e5257d8d6ddcbd46e8e8e07fda87316fcea76ebc1f47ed0da784ba744e6f4e35
3
+ metadata.gz: b68548ae0905b7374d85979f27053683d604ab26c8ae0e66011cd6cf776bf7ad
4
+ data.tar.gz: 863da16427ca424985a8f6ef62237b9a53e2c782fc390950323fd04ad2cb6929
5
5
  SHA512:
6
- metadata.gz: c398fbe3e1740edf11ee63e02dd7f22816d6c0304144844f66e6a41e42415f79e263a6a8d6b8d26b4959a64c6d38a4e7e971d2bc97465daacc3475a611ebd380
7
- data.tar.gz: 3cf2651b1d8754e28f23f682970757b4e4ba3f1cecda3126c127ccb71ebe09f0c9ce2244ea6f48a1119b46d97a110ed15fcc59c0a8f403c8960fc67e6c745b50
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,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'msgpack'
4
- require 'net/http'
5
- require 'uri'
6
4
 
5
+ require_relative 'dem_cache'
6
+ require_relative 'dem_fetch'
7
7
  require_relative 'terrain_lookup'
8
8
 
9
9
  module GitFit
10
10
  module Elevation
11
11
  module TerrainCoeff
12
- CACHE_DIR = ENV.fetch('GIT_FIT_DEM_CACHE_DIR', 'data/cache/dem_fit')
13
- DEM_CACHE_DIR = File.join(CACHE_DIR, 'cache')
12
+ MODEL_CACHE_DIR = ENV.fetch('GIT_FIT_DEM_MODEL_DIR', 'data/cache/dem_fit')
14
13
  BUNDLED_MODEL_DIR = File.expand_path('model', __dir__)
15
14
  BUNDLED_MODEL = File.join(BUNDLED_MODEL_DIR, 'dem_model.msgpack')
16
15
  MODEL_FILE = 'dem_model.msgpack'
17
- DEM_URL = 'https://api.opentopodata.org/v1/srtm30m'
18
16
  GRID = 0.0003
19
17
 
20
18
  class << self
@@ -27,10 +25,10 @@ module GitFit
27
25
  end
28
26
 
29
27
  def find_model
30
- cache_path = File.join(CACHE_DIR, MODEL_FILE)
28
+ cache_path = File.join(MODEL_CACHE_DIR, MODEL_FILE)
31
29
  return cache_path if File.exist?(cache_path)
32
30
  return BUNDLED_MODEL if File.exist?(BUNDLED_MODEL)
33
- 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})"
34
32
  end
35
33
 
36
34
  def reset!
@@ -65,7 +63,16 @@ module GitFit
65
63
  uniq_vals = uniquify_locations(locations)
66
64
  elevs = {}
67
65
  uniq_vals.each_slice(100).with_index do |slice, bi|
68
- elevs.merge!(fetch_dem_batch(slice, bi))
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)
75
+ end
69
76
  sleep 0.15
70
77
  end
71
78
  locations.map { |lat, lng| elevs[grid_key(lat, lng)] }
@@ -77,44 +84,6 @@ module GitFit
77
84
  uniq.values
78
85
  end
79
86
 
80
- def fetch_dem_batch(slice, batch_idx)
81
- elevs = {}
82
- qs = slice.map { |la, ln| "#{la},#{ln}" }.join('|')
83
- uri = URI("#{DEM_URL}?locations=#{qs.gsub(',', '%2C').gsub('|', '%7C')}")
84
- attempt = 0
85
- loop do
86
- # rubocop:disable Style/RedundantBegin
87
- begin
88
- res = Net::HTTP.get_response(uri)
89
- if res.code == '200'
90
- results = JSON.parse(res.body)['results']
91
- slice.each_with_index do |(lat, lng), i|
92
- elevs[grid_key(lat, lng)] = results[i]['elevation'] if results[i]
93
- end
94
- break
95
- elsif res.code == '429'
96
- attempt += 1
97
- if attempt < 5
98
- delay = 2**attempt
99
- warn "DEM batch #{batch_idx}: rate limited, retrying in #{delay}s (attempt #{attempt})"
100
- sleep(delay)
101
- else
102
- warn "DEM batch #{batch_idx}: rate limited, giving up after #{attempt} attempts"
103
- break
104
- end
105
- else
106
- warn "DEM batch #{batch_idx} HTTP #{res.code}"
107
- break
108
- end
109
- rescue StandardError => e
110
- warn "DEM batch #{batch_idx}: #{e.message}"
111
- break
112
- end
113
- # rubocop:enable Style/RedundantBegin
114
- end
115
- elevs
116
- end
117
-
118
87
  def grid_key(lat, lng)
119
88
  "#{(lat / GRID).round}/#{(lng / GRID).round}"
120
89
  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)
@@ -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.3'
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.3
4
+ version: 0.18.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -295,6 +295,8 @@ 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
299
301
  - lib/git_fit/elevation/terrain_lookup.rb
300
302
  - lib/git_fit/elevation/train/collector.rb