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 +4 -4
- data/lib/git_fit/elevation/dem_cache.rb +51 -0
- data/lib/git_fit/elevation/dem_fetch.rb +46 -0
- data/lib/git_fit/elevation/terrain_coeff.rb +25 -54
- data/lib/git_fit/elevation/terrain_lookup.rb +38 -0
- data/lib/git_fit/elevation/train/collector.rb +20 -6
- data/lib/git_fit/elevation/train/runner.rb +49 -12
- data/lib/git_fit/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b68548ae0905b7374d85979f27053683d604ab26c8ae0e66011cd6cf776bf7ad
|
|
4
|
+
data.tar.gz: 863da16427ca424985a8f6ef62237b9a53e2c782fc390950323fd04ad2cb6929
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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(
|
|
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 (#{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
21
|
+
total = collector.count_samples
|
|
22
|
+
puts "Collecting samples (#{total} total)..."
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
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.18.
|
|
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
|