git-fit 0.18.0 → 0.18.2
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/cli/elevation.rb +3 -3
- data/lib/git_fit/elevation/terrain_coeff.rb +10 -10
- data/lib/git_fit/elevation/train/collector.rb +35 -30
- data/lib/git_fit/elevation/train/fitter.rb +6 -6
- data/lib/git_fit/elevation/train/runner.rb +6 -6
- data/lib/git_fit/sync/base.rb +55 -55
- data/lib/git_fit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41afe213d0cfd11467217bc1fca559a90d791d08131f4ad2a8b1be69655cdfbe
|
|
4
|
+
data.tar.gz: bd97306411a2b55af3a0253646cbd43aff7907d4253175ea88372cea457f7e0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eee9145cb899365d6cc669dcc065e6df9d9785ce97522c3e0f561f40225193400f664158b7335395d655b4c5a4d927e835098a39a1a4cb686ff38f4deac455e0
|
|
7
|
+
data.tar.gz: 748ed231a6b7ac6fe293e6c5481da6207cf87472645214019c468ed9f95628f2943bd955980df2e4d56456af6f7dae7891c53b0d67159d9ebb799c20b6e2dbeb
|
|
@@ -57,16 +57,16 @@ module GitFit
|
|
|
57
57
|
r = range_per_km.to_f
|
|
58
58
|
s = seg_density.to_f
|
|
59
59
|
|
|
60
|
-
m = GitFit::Elevation::TerrainCoeff.
|
|
60
|
+
m = GitFit::Elevation::TerrainCoeff.terrain_m(
|
|
61
61
|
gain_per_km: g,
|
|
62
62
|
range_per_km: r,
|
|
63
|
-
seg_density: s
|
|
63
|
+
seg_density: s,
|
|
64
64
|
)
|
|
65
65
|
h = GitFit::Elevation::TerrainCoeff.hysteresis_threshold(m)
|
|
66
66
|
spacing = GitFit::Elevation::TerrainCoeff.dem_spacing(
|
|
67
67
|
gain_per_km: g,
|
|
68
68
|
range_per_km: r,
|
|
69
|
-
seg_density: s
|
|
69
|
+
seg_density: s,
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
say "terrain_M: #{format('%.3f', m)}"
|
|
@@ -36,7 +36,7 @@ module GitFit
|
|
|
36
36
|
@model = nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
39
|
+
def terrain_m(gain_per_km:, range_per_km:, seg_density:)
|
|
40
40
|
g = gain_per_km.to_f
|
|
41
41
|
r = range_per_km.to_f
|
|
42
42
|
s = seg_density.to_f
|
|
@@ -48,21 +48,21 @@ module GitFit
|
|
|
48
48
|
g_n * w[0] + r_n * w[1] + s_n * w[2]
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def hysteresis_threshold(
|
|
52
|
-
return 2.0 if
|
|
51
|
+
def hysteresis_threshold(m_val)
|
|
52
|
+
return 2.0 if m_val <= 0
|
|
53
53
|
pav = model['pav_h']
|
|
54
|
-
return 2.0 if
|
|
55
|
-
return pav[-1][1] if
|
|
56
|
-
pav.each_with_index do |(
|
|
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
57
|
next unless i + 1 < pav.size
|
|
58
|
-
return yk if
|
|
58
|
+
return yk if m_val <= pav[i + 1][0]
|
|
59
59
|
end
|
|
60
60
|
pav[-1][1]
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def dem_spacing(gain_per_km:, range_per_km:, seg_density:)
|
|
64
64
|
nearest_neighbor_spacing(gain_per_km, range_per_km, seg_density)
|
|
65
|
-
rescue => e
|
|
65
|
+
rescue StandardError => e
|
|
66
66
|
warn "TerrainCoeff: dem_spacing fallback to 181m (#{e.message})"
|
|
67
67
|
181
|
|
68
68
|
end
|
|
@@ -75,7 +75,7 @@ module GitFit
|
|
|
75
75
|
elevs = {}
|
|
76
76
|
uniq_vals.each_slice(100).with_index do |slice, bi|
|
|
77
77
|
qs = slice.map { |la, ln| "#{la},#{ln}" }.join('|')
|
|
78
|
-
uri = URI("#{DEM_URL}?locations=#{
|
|
78
|
+
uri = URI("#{DEM_URL}?locations=#{qs.gsub(',', '%2C').gsub('|', '%7C')}")
|
|
79
79
|
begin
|
|
80
80
|
res = Net::HTTP.get_response(uri)
|
|
81
81
|
if res.code == '200'
|
|
@@ -112,7 +112,7 @@ module GitFit
|
|
|
112
112
|
d = Math.sqrt(
|
|
113
113
|
(gain_per_km.to_f - ref['gk'])**2 +
|
|
114
114
|
(range_per_km.to_f - ref['rk'])**2 +
|
|
115
|
-
(seg_density.to_f - ref['sd'])**2
|
|
115
|
+
(seg_density.to_f - ref['sd'])**2,
|
|
116
116
|
)
|
|
117
117
|
next unless d < best_dist
|
|
118
118
|
best_dist = d
|
|
@@ -25,11 +25,9 @@ module GitFit
|
|
|
25
25
|
def each_source_sample(&block)
|
|
26
26
|
adapters = @sources ? resolve_sources(@sources) : all_adapters
|
|
27
27
|
adapters.each do |adapter|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
warn "Collector: #{adapter.name} failed: #{e.message}"
|
|
32
|
-
end
|
|
28
|
+
adapter.training_samples.each(&block)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
warn "Collector: #{adapter.name} failed: #{e.message}"
|
|
33
31
|
end
|
|
34
32
|
end
|
|
35
33
|
|
|
@@ -69,7 +67,7 @@ module GitFit
|
|
|
69
67
|
SPACINGS.each do |sp|
|
|
70
68
|
sel = sample_indices(sm, segs, distances, sp)
|
|
71
69
|
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
72
|
-
err = [
|
|
70
|
+
err = [(lmin - truth_min).abs, (lmax - truth_max).abs].max
|
|
73
71
|
if err <= BUDGET
|
|
74
72
|
chosen = { h: h, spacing: sp, err: err, npts: npts }
|
|
75
73
|
break
|
|
@@ -78,7 +76,7 @@ module GitFit
|
|
|
78
76
|
unless chosen
|
|
79
77
|
sel = sample_indices(sm, segs, distances, SPACINGS.last)
|
|
80
78
|
lmin, lmax, npts = layered_estimate(dem, sel)
|
|
81
|
-
err = [
|
|
79
|
+
err = [(lmin - truth_min).abs, (lmax - truth_max).abs].max
|
|
82
80
|
chosen = { h: h, spacing: SPACINGS.last, err: err, npts: npts }
|
|
83
81
|
end
|
|
84
82
|
best = chosen if best.nil? || chosen[:npts] < best[:npts]
|
|
@@ -98,11 +96,11 @@ module GitFit
|
|
|
98
96
|
npts: best[:npts],
|
|
99
97
|
gain_per_km: f[:gain_per_km],
|
|
100
98
|
range_per_km: f[:range_per_km],
|
|
101
|
-
seg_density: f[:seg_density]
|
|
99
|
+
seg_density: f[:seg_density],
|
|
102
100
|
}
|
|
103
101
|
end
|
|
104
102
|
|
|
105
|
-
def features(altitudes, distances,
|
|
103
|
+
def features(altitudes, distances, _locations)
|
|
106
104
|
return nil if altitudes.empty? || distances.empty?
|
|
107
105
|
|
|
108
106
|
dm = distances.last
|
|
@@ -118,7 +116,7 @@ module GitFit
|
|
|
118
116
|
range_per_km: range_per_km,
|
|
119
117
|
seg_density: seg_density,
|
|
120
118
|
distance_m: dm,
|
|
121
|
-
npts: altitudes.size
|
|
119
|
+
npts: altitudes.size,
|
|
122
120
|
}
|
|
123
121
|
end
|
|
124
122
|
|
|
@@ -135,8 +133,8 @@ module GitFit
|
|
|
135
133
|
moving_avg(moving_median(vals, 9), 9)
|
|
136
134
|
end
|
|
137
135
|
|
|
138
|
-
def moving_median(vals,
|
|
139
|
-
half =
|
|
136
|
+
def moving_median(vals, window)
|
|
137
|
+
half = window / 2
|
|
140
138
|
(0...vals.size).map do |i|
|
|
141
139
|
lo = [0, i - half].max
|
|
142
140
|
hi = [vals.size - 1, i + half].min
|
|
@@ -144,8 +142,8 @@ module GitFit
|
|
|
144
142
|
end
|
|
145
143
|
end
|
|
146
144
|
|
|
147
|
-
def moving_avg(vals,
|
|
148
|
-
half =
|
|
145
|
+
def moving_avg(vals, window)
|
|
146
|
+
half = window / 2
|
|
149
147
|
(0...vals.size).map do |i|
|
|
150
148
|
lo = [0, i - half].max
|
|
151
149
|
hi = [vals.size - 1, i + half].min
|
|
@@ -153,7 +151,7 @@ module GitFit
|
|
|
153
151
|
end
|
|
154
152
|
end
|
|
155
153
|
|
|
156
|
-
def segments(smooth,
|
|
154
|
+
def segments(smooth, threshold, min_len: 20)
|
|
157
155
|
n = smooth.size
|
|
158
156
|
return [[0, n - 1]] if n < 2
|
|
159
157
|
|
|
@@ -166,28 +164,34 @@ module GitFit
|
|
|
166
164
|
v = smooth[i]
|
|
167
165
|
if rising.nil?
|
|
168
166
|
if v > ext_val
|
|
169
|
-
ext_val = v
|
|
167
|
+
ext_val = v
|
|
168
|
+
ext_idx = i
|
|
169
|
+
rising = true
|
|
170
170
|
elsif v < ext_val
|
|
171
|
-
ext_val = v
|
|
171
|
+
ext_val = v
|
|
172
|
+
ext_idx = i
|
|
173
|
+
rising = false
|
|
172
174
|
end
|
|
173
175
|
elsif rising
|
|
174
176
|
if v > ext_val
|
|
175
|
-
ext_val = v
|
|
176
|
-
|
|
177
|
+
ext_val = v
|
|
178
|
+
ext_idx = i
|
|
179
|
+
elsif ext_val - v >= threshold
|
|
177
180
|
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
178
181
|
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
179
182
|
rising = false
|
|
180
|
-
ext_val = v
|
|
181
|
-
|
|
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
|
|
183
|
+
ext_val = v
|
|
184
|
+
ext_idx = i
|
|
190
185
|
end
|
|
186
|
+
elsif v < ext_val
|
|
187
|
+
ext_val = v
|
|
188
|
+
ext_idx = i
|
|
189
|
+
elsif v - ext_val >= threshold
|
|
190
|
+
segs << [seg_start, ext_idx] if ext_idx - seg_start >= min_len
|
|
191
|
+
seg_start = ext_idx if ext_idx - seg_start >= min_len
|
|
192
|
+
rising = true
|
|
193
|
+
ext_val = v
|
|
194
|
+
ext_idx = i
|
|
191
195
|
end
|
|
192
196
|
end
|
|
193
197
|
segs << [seg_start, n - 1] if n - 1 - seg_start >= min_len
|
|
@@ -196,8 +200,9 @@ module GitFit
|
|
|
196
200
|
|
|
197
201
|
def sample_indices(smooth, segs, dist, spacing)
|
|
198
202
|
sel = Set.new
|
|
199
|
-
segs.each { |a, b| sel << a; sel << b }
|
|
200
203
|
segs.each do |a, b|
|
|
204
|
+
sel << a
|
|
205
|
+
sel << b
|
|
201
206
|
next if b - a < 2
|
|
202
207
|
|
|
203
208
|
d0 = dist[a].to_f
|
|
@@ -25,7 +25,7 @@ module GitFit
|
|
|
25
25
|
norms: norms,
|
|
26
26
|
pav_h: pav_h,
|
|
27
27
|
lookup: lookup,
|
|
28
|
-
meta: meta
|
|
28
|
+
meta: meta,
|
|
29
29
|
}
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -101,7 +101,7 @@ module GitFit
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def fit_pav_h
|
|
104
|
-
m_values = @samples.map { |s|
|
|
104
|
+
m_values = @samples.map { |s| terrain_m(s) }
|
|
105
105
|
h_values = @samples.map { |s| s[:h_opt] }
|
|
106
106
|
|
|
107
107
|
sorted = @samples.map.with_index { |_, i| [m_values[i], h_values[i]] }.sort_by(&:first)
|
|
@@ -113,7 +113,7 @@ module GitFit
|
|
|
113
113
|
|
|
114
114
|
sorted.each do |m, h|
|
|
115
115
|
if current_m.nil? || (m - current_m).abs < 0.01
|
|
116
|
-
current_m
|
|
116
|
+
current_m ||= m
|
|
117
117
|
current_h ||= h
|
|
118
118
|
current_h = [current_h, h].min
|
|
119
119
|
count += 1
|
|
@@ -129,7 +129,7 @@ module GitFit
|
|
|
129
129
|
pav
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
def
|
|
132
|
+
def terrain_m(sample)
|
|
133
133
|
g = sample[:gain_per_km].to_f
|
|
134
134
|
r = sample[:range_per_km].to_f
|
|
135
135
|
s = sample[:seg_density].to_f
|
|
@@ -148,7 +148,7 @@ module GitFit
|
|
|
148
148
|
'rk' => s[:range_per_km],
|
|
149
149
|
'sd' => s[:seg_density],
|
|
150
150
|
'h' => s[:h_opt],
|
|
151
|
-
'sp' => s[:spacing_opt]
|
|
151
|
+
'sp' => s[:spacing_opt],
|
|
152
152
|
}
|
|
153
153
|
end
|
|
154
154
|
end
|
|
@@ -157,7 +157,7 @@ module GitFit
|
|
|
157
157
|
{
|
|
158
158
|
'n_samples' => @samples.size,
|
|
159
159
|
'trained_at' => Time.now.strftime('%Y-%m-%d'),
|
|
160
|
-
'sources' => @samples.map { |s| s[:source] }.uniq
|
|
160
|
+
'sources' => @samples.map { |s| s[:source] }.uniq,
|
|
161
161
|
}
|
|
162
162
|
end
|
|
163
163
|
end
|
|
@@ -17,33 +17,33 @@ module GitFit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def run
|
|
20
|
-
puts
|
|
20
|
+
puts 'Collecting samples...'
|
|
21
21
|
collector = Collector.new(sources: @sources)
|
|
22
22
|
samples = collector.collect
|
|
23
23
|
|
|
24
24
|
if samples.empty?
|
|
25
|
-
warn
|
|
25
|
+
warn 'No samples collected'
|
|
26
26
|
return false
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
puts "Collected #{samples.size} samples"
|
|
30
30
|
|
|
31
|
-
puts
|
|
31
|
+
puts 'Fitting model...'
|
|
32
32
|
fitter = Fitter.new(samples)
|
|
33
33
|
model = fitter.fit
|
|
34
34
|
|
|
35
35
|
unless model
|
|
36
|
-
warn
|
|
36
|
+
warn 'Fitting failed'
|
|
37
37
|
return false
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
puts "Writing model to #{@output}..."
|
|
41
41
|
unless fitter.write_msgpack(@output)
|
|
42
|
-
warn
|
|
42
|
+
warn 'Failed to write model'
|
|
43
43
|
return false
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
puts
|
|
46
|
+
puts 'Model trained successfully!'
|
|
47
47
|
puts " n_samples: #{model[:meta]['n_samples']}"
|
|
48
48
|
puts " sources: #{model[:meta]['sources'].join(', ')}"
|
|
49
49
|
puts " ridge_h: #{model[:ridge_h].map { |w| format('%.2f', w) }.join(', ')}"
|
data/lib/git_fit/sync/base.rb
CHANGED
|
@@ -122,6 +122,61 @@ module GitFit
|
|
|
122
122
|
def camelcase_to_snakecase(str)
|
|
123
123
|
str.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
124
124
|
end
|
|
125
|
+
|
|
126
|
+
def training_samples
|
|
127
|
+
std = File.join('data', 'std', config_key)
|
|
128
|
+
return [] unless Dir.exist?(std)
|
|
129
|
+
|
|
130
|
+
Dir.children(std).filter_map do |f|
|
|
131
|
+
next unless f.end_with?('.json')
|
|
132
|
+
|
|
133
|
+
pts = JSON.parse(File.read(File.join(std, f))) rescue next
|
|
134
|
+
next unless pts.is_a?(Array) && pts.any?
|
|
135
|
+
|
|
136
|
+
altitudes = pts.filter_map { |p| p['altitude']&.to_f }
|
|
137
|
+
next if altitudes.size < 2
|
|
138
|
+
|
|
139
|
+
locations = pts.filter_map { |p| extract_location(p) }
|
|
140
|
+
next if locations.size < 2
|
|
141
|
+
|
|
142
|
+
distances = cumulative_distances(locations)
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
altitudes: altitudes,
|
|
146
|
+
distances: distances,
|
|
147
|
+
locations: locations,
|
|
148
|
+
source: config_key,
|
|
149
|
+
id: File.basename(f, '.json'),
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_location(point)
|
|
155
|
+
lat = point['positionLat'] || point['latitude'] || point['lat']
|
|
156
|
+
lng = point['positionLong'] || point['longitude'] || point['lng']
|
|
157
|
+
return nil unless lat && lng
|
|
158
|
+
[lat.to_f, lng.to_f]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def cumulative_distances(locations)
|
|
162
|
+
return [] if locations.size < 2
|
|
163
|
+
|
|
164
|
+
dists = [0.0]
|
|
165
|
+
(1...locations.size).each do |i|
|
|
166
|
+
dists << dists.last + haversine(locations[i - 1], locations[i])
|
|
167
|
+
end
|
|
168
|
+
dists
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def haversine(loc1, loc2)
|
|
172
|
+
r = 6_371_000.0
|
|
173
|
+
dlat = (loc2[0] - loc1[0]) * Math::PI / 180
|
|
174
|
+
dlon = (loc2[1] - loc1[1]) * Math::PI / 180
|
|
175
|
+
la1 = loc1[0] * Math::PI / 180
|
|
176
|
+
la2 = loc2[0] * Math::PI / 180
|
|
177
|
+
h = Math.sin(dlat / 2)**2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dlon / 2)**2
|
|
178
|
+
2 * r * Math.asin(Math.sqrt(h))
|
|
179
|
+
end
|
|
125
180
|
end
|
|
126
181
|
|
|
127
182
|
protected
|
|
@@ -208,61 +263,6 @@ module GitFit
|
|
|
208
263
|
raise NotImplementedError, "#{self.class} must implement #raw_path"
|
|
209
264
|
end
|
|
210
265
|
|
|
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
|
-
|
|
266
266
|
def write_source_archive(data, platform_id, ext = 'json')
|
|
267
267
|
dir = raw_dir
|
|
268
268
|
FileUtils.mkdir_p(dir)
|
data/lib/git_fit/version.rb
CHANGED