yr_weather 1.0.0

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/yr_weather.rb +362 -0
  3. metadata +46 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bbf68e1623f53a5a3ecb6d38a5c79d20167aa70b5d06f18dfc8a672658bd0070
4
+ data.tar.gz: cbb5cc94eaf848499f757ce9c7ba45d05f6e1f6dddd931eb4d8f96ae460104b9
5
+ SHA512:
6
+ metadata.gz: 761d8bac6187feff85ff34980a0149ea8dfe986fe825986fcba2ab8569d78ecd072370a63b7e68f7a8e124dcabbad98fea365e84ee8ba5075dc2316a583b32e8
7
+ data.tar.gz: e76bf5b6974cb8eed9fc9c26ce488e38c2b8a5a2e791568e08a7a17dba022929633823261945b988cb316d84349040cf32b7a715b37c7dc01c38605b16c5f83c
@@ -0,0 +1,362 @@
1
+ require 'json'
2
+ require 'time'
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'tmpdir'
6
+
7
+ class YrWeather
8
+
9
+ class << self
10
+ attr_accessor :configuration
11
+ end
12
+
13
+ def self.config
14
+ self.configuration ||= YrWeather::Configuration.new
15
+ yield(configuration)
16
+ end
17
+
18
+ class YrWeather::Configuration
19
+ attr_accessor :sitename
20
+ attr_accessor :redis
21
+ attr_accessor :utc_offset
22
+ def initialize
23
+ @sitename = nil
24
+ @redis = nil
25
+ @utc_offset = nil
26
+ end
27
+ end
28
+
29
+ class YrWeather::RedisCache
30
+
31
+ def initialize(params)
32
+ @latitude = params[:latitude]
33
+ @longitude = params[:longitude]
34
+ @redis = params[:redis]
35
+ end
36
+
37
+ def to_cache(data)
38
+ seconds_to_cache = (data[:expires] - Time.now).ceil
39
+ seconds_to_cache = 60 if seconds_to_cache < 60
40
+ @redis.set(redis_key, data.to_json, ex: seconds_to_cache)
41
+ end
42
+
43
+ def from_cache
44
+ @redis.get(redis_key)
45
+ end
46
+
47
+ private
48
+
49
+ def redis_key
50
+ "yr_weather.#{@latitude}.#{@longitude}"
51
+ end
52
+
53
+ end
54
+
55
+ class YrWeather::FileCache
56
+
57
+ def initialize(params)
58
+ @latitude = params[:latitude]
59
+ @longitude = params[:longitude]
60
+ end
61
+
62
+ def to_cache(data)
63
+ file_name = cache_file_name
64
+ File.write(file_name, data.to_json)
65
+ end
66
+
67
+ def from_cache
68
+ file_name = cache_file_name
69
+ if File.file?(cache_file_name)
70
+ File.read(file_name)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def cache_file_name
77
+ file_name = "yr_weather.#{@latitude}.#{@longitude}.tmp"
78
+ File.join(Dir.tmpdir,file_name)
79
+ end
80
+
81
+ end
82
+
83
+
84
+ YR_NO = 'https://api.met.no/weatherapi/locationforecast/2.0/complete'
85
+ COMPASS_BEARINGS = %w(N NE E SE S SW W NW)
86
+ ARC = 360.0/COMPASS_BEARINGS.length.to_f
87
+
88
+ @latitude = nil
89
+ @longitude = nil
90
+ @utc_offset = nil
91
+
92
+ @data = nil
93
+ @now = Time.now
94
+ @start_of_day = nil
95
+
96
+ # @latitude = -33.9531096408383
97
+ # @longitude = 18.4806353422955
98
+
99
+ # YrWeather.get(latitude: -33.9531096408383, longitude: 18.4806353422955)
100
+ # def self.get(sitename:, latitude:, longitude:, utc_offset: '+00:00', limit_to: nil)
101
+ # YrWeather.new(latitude: latitude, longitude: longitude, utc_offset: utc_offset).process(limit_to)
102
+ # end
103
+
104
+ def initialize(latitude:, longitude:, utc_offset: nil)
105
+ @latitude = latitude.round(4) # yr developer page requests four decimals.
106
+ @longitude = longitude.round(4)
107
+ @utc_offset = utc_offset || YrWeather.configuration.utc_offset || '+00:00'
108
+ @now = Time.now.localtime(@utc_offset)
109
+ @start_of_day = Time.local(@now.year, @now.month, @now.day)
110
+ @start_of_day = @start_of_day + 24*60*60 if @now.hour >= 20
111
+ raise 'yr.no reqiure a sitename and email. See readme for details' unless YrWeather.configuration.sitename=~/@/
112
+ params = { latitude: @latitude, longitude: @longitude, redis: YrWeather.configuration.redis }
113
+ @cacher = (YrWeather.configuration.redis ? YrWeather::RedisCache.new(params) : YrWeather::FileCache.new(params))
114
+ @data = load_forecast
115
+ end
116
+
117
+ def initialised?
118
+ !@data.nil?
119
+ end
120
+
121
+ def raw
122
+ @data
123
+ end
124
+
125
+ def metadata
126
+ {
127
+ forecast_updated_at: @data.dig(:properties, :meta, :updated_at),
128
+ downloaded_at: @data[:downloaded_at],
129
+ expires_at: @data[:expires],
130
+ start_of_day: @start_of_day,
131
+ latitude: @data.dig(:geometry, :coordinates)[1],
132
+ longitude: @data.dig(:geometry, :coordinates)[0],
133
+ msl: @data.dig(:geometry, :coordinates)[2],
134
+ units: @data.dig(:properties, :meta, :units),
135
+ }
136
+ end
137
+
138
+ def current
139
+ time = @data.dig(:properties, :timeseries).map { |e| e[:time] }.reject { |e| e>@now }.sort.last
140
+ node = @data.dig(:properties, :timeseries).select { |e| e[:time]==time }.first
141
+ node.dig(:data, :instant, :details).merge({
142
+ at: time,
143
+ symbol_code: node.dig(:data, :next_1_hours, :summary, :symbol_code),
144
+ precipitation_amount: node.dig(:data, :next_1_hours, :details, :precipitation_amount),
145
+ wind_from_direction: degrees_to_bearing(node.dig(:data, :instant, :details, :wind_from_direction)),
146
+ wind_description: wind_description(node.dig(:data, :instant, :details, :wind_speed)),
147
+ wind_speed_knots: to_knots(node.dig(:data, :instant, :details, :wind_speed)),
148
+ })
149
+ end
150
+
151
+ def next_12_hours
152
+ range = @now..(@now + 12*60*60)
153
+ forecast(range).merge(symbol: symbol_code_hourly(range))
154
+ end
155
+
156
+ def three_days
157
+ range = @now..(@now + 3*24*60*60)
158
+ forecast(range).tap { |hs| hs.delete(:wind_description) }
159
+ end
160
+
161
+ def week
162
+ range = @now..(@now + 7*24*60*60)
163
+ forecast(range).tap { |hs| hs.delete(:wind_description) }
164
+ end
165
+
166
+ def six_hourly
167
+ t = @start_of_day
168
+ loop do
169
+ if (t + 6*60*60) > Time.now
170
+ break
171
+ else
172
+ t = t + 6*60*60
173
+ end
174
+ end
175
+ nodes = @data.dig(:properties, :timeseries).select { |e| e.dig(:data, :next_6_hours) }.map { |e| [e[:time], e] }.to_h
176
+ nodes = 20.times.map do |i|
177
+ nodes[t + i*6*60*60]
178
+ end.compact.map do |node|
179
+ {
180
+ at: node.dig(:time),
181
+ temperature_maximum: node.dig(:data, :next_6_hours, :details, :air_temperature_max),
182
+ temperature_minimum: node.dig(:data, :next_6_hours, :details, :air_temperature_min),
183
+ wind_speed_max: node.dig(:data, :instant, :details, :wind_speed),
184
+ wind_speed_max_knots: to_knots(node.dig(:data, :instant, :details, :wind_speed)),
185
+ wind_direction: degrees_to_bearing(node.dig(:data, :instant, :details, :wind_from_direction)),
186
+ wind_description: node.dig(:data, :instant, :details, :wind_speed),
187
+ precipitation: node.dig(:data, :next_6_hours, :details, :precipitation_amount),
188
+ symbol_code: node.dig(:data, :next_6_hours, :summary, :symbol_code),
189
+ }
190
+ end
191
+ end
192
+
193
+ def daily
194
+ 8.times.map do |day|
195
+ start = @start_of_day + day*24*60*60
196
+ range = start..(start + 24*60*60)
197
+ forecast(range).merge(at: start)
198
+ end
199
+ end
200
+
201
+ def arrays
202
+ nodes = @data.dig(:properties, :timeseries)
203
+ points = nodes.map do |node|
204
+ {
205
+ at: node[:time],
206
+ temperature: node.dig(:data, :instant, :details, :air_temperature),
207
+ wind_speed: node.dig(:data, :instant, :details, :wind_speed),
208
+ precipitation: node.dig(:data, :next_1_hours, :details, :precipitation_amount) || node.dig(:data, :next_6_hours, :details, :precipitation_amount),
209
+ hours: ( node.dig(:data, :next_1_hours, :details, :precipitation_amount) ? 1 : 6),
210
+ }
211
+ end
212
+ results = {
213
+ at: [],
214
+ temperature: [],
215
+ wind_speed: [],
216
+ wind_speed_knots: [],
217
+ precipitation: [],
218
+ hours: [],
219
+ }
220
+ points.each do |point|
221
+ point[:hours].times do |i|
222
+ results[:at] << point[:at] + i*60*60
223
+ results[:temperature] << point[:temperature]
224
+ results[:wind_speed] << point[:wind_speed]
225
+ results[:wind_speed_knots] << to_knots(point[:wind_speed])
226
+ results[:precipitation] << ((point[:precipitation].to_f) / (point[:hours].to_f)).round(1)
227
+ end
228
+ end
229
+ results
230
+ end
231
+
232
+
233
+ private
234
+
235
+ def forecast(range)
236
+ nodes = nodes_for_range(range)
237
+ detail = nodes.map { |e| e.dig(:data, :instant, :details) }
238
+ wind_directions = detail.map { |e| degrees_to_bearing(e[:wind_from_direction]) }
239
+ {
240
+ temperature_maximum: detail.map { |e| e[:air_temperature] }.max,
241
+ temperature_minimum: detail.map { |e| e[:air_temperature] }.min,
242
+ wind_speed_max: detail.map { |e| e[:wind_speed] }.max,
243
+ wind_speed_max_knots: to_knots(detail.map { |e| e[:wind_speed] }.max),
244
+ wind_description: wind_description(detail.map { |e| e[:wind_speed] }.max),
245
+ wind_direction: wind_directions.max_by { |e| wind_directions.count(e) },
246
+ precipitation: precipitation(range, nodes)
247
+ }
248
+ end
249
+
250
+ def precipitation(range, nodes)
251
+ next_time = range.first
252
+ end_time = range.last
253
+ nodes.map do |node|
254
+ mm = nil
255
+ if node[:time] >= next_time
256
+ [1,6,12].each do |i|
257
+ mm = node.dig(:data, "next_#{i}_hours".to_sym, :details, :precipitation_amount)
258
+ if mm
259
+ next_time = next_time + i*60*60
260
+ break
261
+ end
262
+ end
263
+ end
264
+ mm
265
+ end.sum
266
+ end
267
+
268
+ def symbol_code_hourly(range)
269
+ symbols = nodes_for_range(@now..(@now + 12*60*60)).map { |e| e.dig(:data, :next_1_hours, :summary, :symbol_code) }
270
+ symbols.max_by { |e| symbols.count(e) }
271
+ end
272
+
273
+ def nodes_for_range(range)
274
+ @data.dig(:properties, :timeseries).select { |e| range.include?(e[:time]) }
275
+ end
276
+
277
+ def degrees_to_bearing(degrees)
278
+ COMPASS_BEARINGS[(degrees.to_f/ARC).round % COMPASS_BEARINGS.length]
279
+ end
280
+
281
+ def to_knots(ms)
282
+ ( ms ? (ms*1.943844).round(1) : nil )
283
+ end
284
+
285
+ def wind_description(speed)
286
+ ms = speed.round(1)
287
+ case ms
288
+ when (0..(0.5)) then 'calm'
289
+ when ((0.5)..(1.5)) then 'light air'
290
+ when ((1.6)..(3.3)) then 'light breeze'
291
+ when ((4)..(5.5)) then 'gentle breeze'
292
+ when ((5.5)..(7.9)) then 'moderate breeze'
293
+ when ((8)..(10.7)) then 'fresh breeze'
294
+ when ((10.8)..(13.8)) then 'strong breeze'
295
+ when ((13.9)..(17.1)) then 'high wind,'
296
+ when ((17.2)..(20.7)) then 'gale'
297
+ when ((20.8)..(24.4)) then 'strong gale'
298
+ when ((24.5)..(28.4)) then 'storm'
299
+ when ((28.5)..(32.6)) then 'violent storm'
300
+ else 'hurricane force'
301
+ end
302
+ end
303
+
304
+
305
+ def load_forecast
306
+ data = @cacher.from_cache
307
+ data = parse_json(data) if !data.nil?
308
+ if data.nil?
309
+ data = forecast_from_yr
310
+ @cacher.to_cache(data)
311
+ end
312
+ data
313
+ end
314
+
315
+ def parse_json(json)
316
+ parse_times(JSON.parse(json, symbolize_names: true))
317
+ end
318
+
319
+
320
+ def parse_times(hash)
321
+ if (hash.is_a?(Hash))
322
+ hash.transform_values do |v|
323
+ if v.is_a?(Hash)
324
+ parse_times(v)
325
+ elsif v.is_a?(Array)
326
+ v.map { |e| parse_times(e) }
327
+ elsif v.is_a?(String) && v=~/\d{4}-\d\d-\d\d[\sT]\d\d:\d\d:\d\d/
328
+ Time.parse(v)
329
+ # r = Time.parse(v) rescue nil
330
+ # (r || v)
331
+ else
332
+ v
333
+ end
334
+ end
335
+ else
336
+ hash
337
+ end
338
+ end
339
+
340
+
341
+
342
+ # def parse
343
+ # %w(hourly today tomorrow three_days week daily daily_objects hourly_objects).map(&:to_sym).map { |e| [e, self.send(e)] }
344
+ # end
345
+
346
+ def forecast_from_yr
347
+ url = URI("#{YR_NO}?lat=#{@latitude}&lon=#{@longitude}")
348
+ https = Net::HTTP.new(url.host, url.port)
349
+ https.use_ssl = true
350
+ request = Net::HTTP::Get.new(url)
351
+ request["Content-Type"] = "application/json"
352
+ request["User-Agent"] = YrWeather.configuration.sitename
353
+ response = https.request(request)
354
+ {
355
+ expires: Time.parse(response['expires']),
356
+ last_modified: Time.parse(response['last-modified']),
357
+ downloaded_at: Time.now,
358
+ }.merge(parse_json(response.body))
359
+ end
360
+
361
+
362
+ end
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yr_weather
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - renen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-01-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: This gem leverages the yr.no weather API to convert location data into
14
+ hourly forecasts, and summaries that are simpler to understand, and easy to script
15
+ into databases and systems.
16
+ email:
17
+ - renen@121.co.za
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - lib/yr_weather.rb
23
+ homepage: https://github.com/sasa-solutions/yr_parser
24
+ licenses:
25
+ - MIT
26
+ metadata: {}
27
+ post_install_message:
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 3.0.1
43
+ signing_key:
44
+ specification_version: 4
45
+ summary: Easily interpret and use yr.no weather forecast APIs
46
+ test_files: []