├── Rakefile ├── example.png ├── Gemfile ├── bin ├── setup └── console ├── lib ├── yr_weather │ └── configuration.rb └── yr_weather.rb ├── test.rb ├── yr_weather.gemspec ├── LICENSE ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | task :default => :spec 3 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasa-solutions/yr_weather/HEAD/example.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in yr_parser.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/yr_weather/configuration.rb: -------------------------------------------------------------------------------- 1 | class YrParser::Configuration 2 | attr_accessor :sitename 3 | attr_accessor :redis 4 | attr_accessor :utc_offset 5 | def initialize 6 | @sitename = nil 7 | @redis = nil 8 | @utc_offset = nil 9 | end 10 | end -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "yr_parser" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /test.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require_relative 'lib/yr_weather' 3 | 4 | YrWeather.config do |c| 5 | c.sitename = 'sasa.solutions;info@sasa.solutions' 6 | c.utc_offset = '+02:00' 7 | c.redis = Redis.new(:host => 'localhost', :port => 6379) 8 | end 9 | 10 | parser = YrWeather.new(latitude: -33.9531096408383, longitude: 18.4806353422955) 11 | # pp parser.initialised? 12 | # pp parser.metadata 13 | # pp parser.current 14 | # pp parser.next_12_hours 15 | # pp parser.daily 16 | # pp parser.six_hourly 17 | pp parser.tomorrow 18 | # pp parser.three_days 19 | # pp parser.week 20 | # pp parser.arrays.keys 21 | -------------------------------------------------------------------------------- /yr_weather.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | Gem::Specification.new do |spec| 6 | 7 | spec.name = "yr_weather" 8 | spec.version = '1.0.4' 9 | spec.date = '2021-01-28' 10 | spec.licenses = ['MIT'] 11 | 12 | spec.authors = ["renen"] 13 | spec.email = ["renen@121.co.za"] 14 | 15 | spec.summary = 'Easily interpret and use yr.no weather forecast APIs' 16 | spec.description = 'Detailed, accurate, forecast data from yr.no. Converts location data into usable forecasts (for different periods), as well as into summaries that are simple to understand, and easy to use.' 17 | spec.homepage = 'https://github.com/sasa-solutions/yr_weather' 18 | 19 | spec.files = ['lib/yr_weather.rb'] 20 | 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 sasa-solutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at renen@121.co.za. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yr_weather 2 | 3 | This gem converts yr.no forecasts into application friendly structures. It summarises and aggregates the data to make it easy to deliver weather forecasts, for any place on earth, using the awesome yr.no forecasts. 4 | 5 | Specifically, the Gem repackages yr's data to make it easy to: 6 | 1. Present it 7 | 2. Draw graphs 8 | 3. Make decisions 9 | 4. Script forecasts into databases or other systems 10 | 11 | The Gem deals with caching (either using the file system, or in Redis), and constructs the API request to YR in a manner that complies with their [requirements](https://developer.yr.no/doc/locationforecast/HowTO/). 12 | 13 | Takes the heavy lifting out of creating a forecast like this: 14 | 15 | ![alt text](https://raw.githubusercontent.com/sasa-solutions/yr_weather/main/example.png) 16 | 17 | Code is below. 18 | 19 | ## Terms of Service 20 | YR are pretty permissive. There is a request for attribution, and a twenty requests per second rate limit. Before you get to far, please review [this](https://developer.yr.no/doc/TermsOfService/). 21 | 22 | ## Installation 23 | Either add it to your Gemfile: 24 | `gem 'yr_weather'` 25 | 26 | And then `bundle install`. 27 | 28 | Or: `bundle install yr_weather` 29 | 30 | ## Configuration 31 | While YR don't require any registration or API keys, they do ask that you provide a site name and email address. Specifically: 32 | > the name of your website and some contact info (e.g. a GitHub URL, a non-personal email address, a working website or a mobile app store appname) 33 | 34 | To this end, the gem will require at least an `@` symbol in the site name. 35 | 36 | `/config/initializers/weather.rb` 37 | ``` 38 | YrWeather.config do |c| 39 | c.sitename = ';' 40 | c.utc_offset = '+02:00' 41 | c.redis = Redis.new(:host => 'localhost', :port => 6379) 42 | end 43 | ``` 44 | `utc_offset` is optional, and is used to calculate the day start time. 45 | 46 | If `redis` is configured, it will be used to cache the forecasts. If it is `nil` or omitted, the gem will cache data in the file system. 47 | ## Usage 48 | ``` 49 | parser = YrWeather.new(latitude: -33.953109, longitude: 18.480635) 50 | pp parser.metadata 51 | pp parser.current 52 | pp parser.next_12_hours 53 | pp parser.daily 54 | pp parser.six_hourly 55 | pp parser.tomorrow 56 | pp parser.three_days 57 | pp parser.week 58 | pp parser.arrays 59 | ``` 60 | 61 | Method|Description 62 | --|--| 63 | `metadata`|Returns a hash describing the forecast. It includes units, expiry times, and geographic detail. 64 | `current`|Current meteorological conditions: temperature, wind speed, etc. 65 | `next_12_hours`|Conditions over the next twelve hours. 66 | `daily`|For the next week, per day, minima and maxima, maximum windspeeds, rainfall, etc. 67 | `six_hourly`|Six hourly forecast detail. 68 | tomorrow|Tomorrow's maximum and minimum temperature, maximum windspeed, as well as cumulative forecast precipitation. 69 | `three_days`|Maximum and minimum temperature over the next three days, maximum windspeed, as well as cumulative forecast precipitation for those three days. 70 | `week`|Maximum and minimum temperature over the next week, maximum windspeed, as well as cumulative forecast precipitation for the week. 71 | `arrays`|A hash of six, equally sized, arrays: `at`, `temperature`, `wind_speed`, `wind_speed_knots`, `precipitation`, and `hours`. Use this data for graphing. 72 | ## Returned Values 73 | You will generally get back hashes, with some or all of the following: 74 | Parameter|Description 75 | --|--| 76 | `temperature_maximum`|Maximum temperature 77 | `temperature_minimum`|Minimum temperature 78 | `wind_speed_max`|Wind speed (meters per second) 79 | `wind_speed_max_knots`|Wind speed (knots) 80 | `wind_description`|A Beaufort scale descriptor: _Breezy_ or _hurricane force_. Human friendly. 81 | `wind_direction`|`N`, `S`, `SE` etc. Will be the predominant wind direction for the period. 82 | `precipitation`|How much it's going to rain in that period. 83 | `from`, `to`, `at`|The range or time that this forecast, or forecast period, relates to. 84 | `symbol_code`|Maps to an [icon](https://api.met.no/weatherapi/weathericon/2.0/documentation). 85 | 86 | ## Icons 87 | YR provide a set of icons [here](https://api.met.no/weatherapi/weathericon/2.0/documentation). 88 | ## Caching 89 | YR only run their model periodically. As such, there's no point in beating up their API's endlessly. The gem will cache results based on the "expires" guidance provided by their API servers. 90 | 91 | The gem will either need to be able to write to Redis, or to the Linux temporary directory (internally, we call `Dir.tmpdir`). 92 | 93 | ## Dependencies 94 | Requires a reasonably recent version of ruby. There are no other dependencies. 95 | ## Sample Code 96 | To render the forecast as illustrated above: 97 | ``` 98 | <% 99 | @weather = YrWeather.new(latitude: @latitude, longitude: @longitude) 100 | current = @weather.current 101 | %> 102 | 103 |
104 | 105 |
106 |
107 |

Current Conditions

108 |
109 |
110 |
111 | <%=current[:air_temperature].round%>°C
112 | <%=current[:wind_direction]%> <%=current[:wind_speed].round%> ms-1 (<%=current[:wind_description]%>)
113 | <%=current[:precipitation_amount]%> mm rain
114 |
115 |
116 |
117 |
118 | 119 |
120 |
121 |

Three Day View

122 |
123 |
124 | High: <%=@weather.three_days[:temperature_maximum].round%>°C
125 | Low: <%=@weather.three_days[:temperature_minimum].round%>°C
126 | Max: <%=@weather.three_days[:wind_speed_max].round%> ms-1 mostly <%=@weather.three_days[:wind_direction]%>
127 | Cumm: <%=@weather.three_days[:precipitation].round%> mm rain
128 |
129 |
130 |
131 |
132 | 133 |
134 |
135 |

Week View

136 |
137 |
138 | High: <%=@weather.week[:temperature_maximum].round%>°C
139 | Low: <%=@weather.week[:temperature_minimum].round%>°C
140 | Max: <%=@weather.week[:wind_speed_max].round%> ms-1 mostly <%=@weather.week[:wind_direction]%>
141 | Cumm: <%=@weather.week[:precipitation].round%> mm rain
142 |
143 |
144 |
145 |
146 | 147 |
148 | 149 |
150 | <% @weather.six_hourly.each do |forecast| %> 151 |
152 |
153 |
154 |
<%=forecast[:from]%> to <%=forecast[:to]%>
155 |
156 |
157 |

158 | Max: <%=forecast[:temperature_maximum].round%>°C
159 | <%=forecast[:wind_direction]%> <%=forecast[:wind_speed_max]%> ms-1
160 | <%=forecast[:precipitation]%> mm rain
161 |

162 |
163 |
164 |
165 |
166 | <% end %> 167 |
168 | ``` 169 | This code has dependencies on Bootstrap, as well as some JavaScript magic we use for formatting dates and times. But, hopefully you get a sense of what's involved in using the gem. 170 | 171 | ## Contributing 172 | Please do! There's plenty that can be improved here! 173 | -------------------------------------------------------------------------------- /lib/yr_weather.rb: -------------------------------------------------------------------------------- 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_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 tomorrow 157 | range = (@start_of_day + 24*60*60)..(@start_of_day + 2*24*60*60) 158 | forecast(range).tap { |hs| hs.delete(:wind_description) } 159 | end 160 | 161 | def three_days 162 | range = @now..(@now + 3*24*60*60) 163 | forecast(range).tap { |hs| hs.delete(:wind_description) } 164 | end 165 | 166 | def week 167 | range = @now..(@now + 7*24*60*60) 168 | forecast(range).tap { |hs| hs.delete(:wind_description) } 169 | end 170 | 171 | def six_hourly 172 | t = @start_of_day 173 | loop do 174 | if (t + 6*60*60) > Time.now 175 | break 176 | else 177 | t = t + 6*60*60 178 | end 179 | end 180 | nodes = @data.dig(:properties, :timeseries).select { |e| e.dig(:data, :next_6_hours) }.map { |e| [e[:time], e] }.to_h 181 | nodes = 20.times.map do |i| 182 | nodes[t + i*6*60*60] 183 | end.compact.map do |node| 184 | { 185 | from: node.dig(:time), 186 | to: node.dig(:time) + 6*60*60, 187 | temperature_maximum: node.dig(:data, :next_6_hours, :details, :air_temperature_max), 188 | temperature_minimum: node.dig(:data, :next_6_hours, :details, :air_temperature_min), 189 | wind_speed_max: node.dig(:data, :instant, :details, :wind_speed), 190 | wind_speed_max_knots: to_knots(node.dig(:data, :instant, :details, :wind_speed)), 191 | wind_direction: degrees_to_bearing(node.dig(:data, :instant, :details, :wind_from_direction)), 192 | wind_description: wind_description(node.dig(:data, :instant, :details, :wind_speed)), 193 | precipitation: node.dig(:data, :next_6_hours, :details, :precipitation_amount), 194 | symbol_code: node.dig(:data, :next_6_hours, :summary, :symbol_code), 195 | } 196 | end 197 | end 198 | 199 | def daily 200 | 8.times.map do |day| 201 | start = @start_of_day + day*24*60*60 202 | range = start..(start + 24*60*60) 203 | forecast(range).merge(from: start, to: start + 24*60*60) 204 | end 205 | end 206 | 207 | def arrays 208 | nodes = @data.dig(:properties, :timeseries) 209 | points = nodes.map do |node| 210 | { 211 | at: node[:time], 212 | temperature: node.dig(:data, :instant, :details, :air_temperature), 213 | wind_speed: node.dig(:data, :instant, :details, :wind_speed), 214 | precipitation: node.dig(:data, :next_1_hours, :details, :precipitation_amount) || node.dig(:data, :next_6_hours, :details, :precipitation_amount), 215 | hours: ( node.dig(:data, :next_1_hours, :details, :precipitation_amount) ? 1 : 6), 216 | } 217 | end 218 | results = { 219 | from: [], 220 | to: [], 221 | temperature: [], 222 | wind_speed: [], 223 | wind_speed_knots: [], 224 | precipitation: [], 225 | hours: [], 226 | } 227 | points.each do |point| 228 | point[:hours].times do |i| 229 | results[:from] << point[:at] + i*60*60 230 | results[:to] << point[:at] + (i+1)*60*60 231 | results[:temperature] << point[:temperature] 232 | results[:wind_speed] << point[:wind_speed] 233 | results[:wind_speed_knots] << to_knots(point[:wind_speed]) 234 | results[:precipitation] << ((point[:precipitation].to_f) / (point[:hours].to_f)).round(1) 235 | end 236 | end 237 | results 238 | end 239 | 240 | 241 | private 242 | 243 | def forecast(range) 244 | nodes = nodes_for_range(range) 245 | detail = nodes.map { |e| e.dig(:data, :instant, :details) } 246 | wind_directions = detail.map { |e| degrees_to_bearing(e[:wind_from_direction]) } 247 | { 248 | temperature_maximum: detail.map { |e| e[:air_temperature] }.max, 249 | temperature_minimum: detail.map { |e| e[:air_temperature] }.min, 250 | wind_speed_max: detail.map { |e| e[:wind_speed] }.max, 251 | wind_speed_max_knots: to_knots(detail.map { |e| e[:wind_speed] }.max), 252 | wind_description: wind_description(detail.map { |e| e[:wind_speed] }.max), 253 | wind_direction: wind_directions.max_by { |e| wind_directions.count(e) }, 254 | precipitation: precipitation(range, nodes) 255 | } 256 | end 257 | 258 | def precipitation(range, nodes) 259 | next_time = range.first 260 | end_time = range.last 261 | nodes.map do |node| 262 | mm = nil 263 | if node[:time] >= next_time 264 | [1,6,12].each do |i| 265 | mm = node.dig(:data, "next_#{i}_hours".to_sym, :details, :precipitation_amount) 266 | if mm 267 | next_time = next_time + i*60*60 268 | break 269 | end 270 | end 271 | end 272 | mm 273 | end.sum 274 | end 275 | 276 | def symbol_code_hourly(range) 277 | symbols = nodes_for_range(@now..(@now + 12*60*60)).map { |e| e.dig(:data, :next_1_hours, :summary, :symbol_code) } 278 | symbols.max_by { |e| symbols.count(e) } 279 | end 280 | 281 | def nodes_for_range(range) 282 | @data.dig(:properties, :timeseries).select { |e| range.include?(e[:time]) } 283 | end 284 | 285 | def degrees_to_bearing(degrees) 286 | COMPASS_BEARINGS[(degrees.to_f/ARC).round % COMPASS_BEARINGS.length] 287 | end 288 | 289 | def to_knots(ms) 290 | ( ms ? (ms*1.943844).round(1) : nil ) 291 | end 292 | 293 | def wind_description(speed) 294 | ms = speed.round(1) 295 | case ms 296 | when (0..(0.5)) then 'calm' 297 | when ((0.5)..(1.5)) then 'light air' 298 | when ((1.6)..(3.3)) then 'light breeze' 299 | when ((4)..(5.5)) then 'gentle breeze' 300 | when ((5.5)..(7.9)) then 'moderate breeze' 301 | when ((8)..(10.7)) then 'fresh breeze' 302 | when ((10.8)..(13.8)) then 'strong breeze' 303 | when ((13.9)..(17.1)) then 'high wind,' 304 | when ((17.2)..(20.7)) then 'gale' 305 | when ((20.8)..(24.4)) then 'strong gale' 306 | when ((24.5)..(28.4)) then 'storm' 307 | when ((28.5)..(32.6)) then 'violent storm' 308 | else 'hurricane force' 309 | end 310 | end 311 | 312 | 313 | def load_forecast 314 | data = @cacher.from_cache 315 | data = parse_json(data) if !data.nil? 316 | if data.nil? 317 | data = forecast_from_yr 318 | @cacher.to_cache(data) 319 | end 320 | data 321 | end 322 | 323 | def parse_json(json) 324 | parse_times(JSON.parse(json, symbolize_names: true)) 325 | end 326 | 327 | 328 | def parse_times(hash) 329 | if (hash.is_a?(Hash)) 330 | hash.transform_values do |v| 331 | if v.is_a?(Hash) 332 | parse_times(v) 333 | elsif v.is_a?(Array) 334 | v.map { |e| parse_times(e) } 335 | elsif v.is_a?(String) && v=~/\d{4}-\d\d-\d\d[\sT]\d\d:\d\d:\d\d/ 336 | Time.parse(v) 337 | # r = Time.parse(v) rescue nil 338 | # (r || v) 339 | else 340 | v 341 | end 342 | end 343 | else 344 | hash 345 | end 346 | end 347 | 348 | 349 | 350 | # def parse 351 | # %w(hourly today tomorrow three_days week daily daily_objects hourly_objects).map(&:to_sym).map { |e| [e, self.send(e)] } 352 | # end 353 | 354 | def forecast_from_yr 355 | url = URI("#{YR_NO}?lat=#{@latitude}&lon=#{@longitude}") 356 | https = Net::HTTP.new(url.host, url.port) 357 | https.use_ssl = true 358 | request = Net::HTTP::Get.new(url) 359 | request["Content-Type"] = "application/json" 360 | request["User-Agent"] = YrWeather.configuration.sitename 361 | response = https.request(request) 362 | { 363 | expires: Time.parse(response['expires']), 364 | last_modified: Time.parse(response['last-modified']), 365 | downloaded_at: Time.now, 366 | }.merge(parse_json(response.body)) 367 | end 368 | 369 | 370 | end --------------------------------------------------------------------------------