forecaster 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 2a5197a183958f87c3231d9794568fd71794c5b5
4
- data.tar.gz: d33426c83219a0a7d813be7dea525a8426cc61ad
3
+ metadata.gz: f918096d5bc3c84b8b1be9ded8a0e9597a3bf700
4
+ data.tar.gz: 73a46ed9e59a3c3f05cc45c5a654d8548055fa58
5
5
  SHA512:
6
- metadata.gz: f7c2cda2ff2a646779c9326ef40b93c8e12593753dd62c3418f7def70b4bc9104640a1353652259a613b17dfb686bce95486bf9f6b286511a0a15f1da966b2d3
7
- data.tar.gz: 973f053a188acd33f48f4305280f5106181749d65942c86bff946c98921fe8b18377d9b35c3687fb1c0107bf286ac8082e77aa36fac4b98539c66a72cfa5d009
6
+ metadata.gz: dac29049eb42eb4cee58446c28a15d090c9abdfe76426db8c732e499b2cd1dcf8f9405d7a0bfa3479496525291de9e417c54a3cbdc687b87b1d9d73aa5fbe05a
7
+ data.tar.gz: 7406bb7e8d9de0afa5ff34854d826d71a34e5412a14871f82c0766d3f9793e0f212238473c32176518f8c45ed6402081efe43d7b3beee83f13dbd2a4f5221e71
data/bin/forecast ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "forecaster"
4
+
5
+ Forecaster::CLI.start(ARGV, ENV)
@@ -0,0 +1,243 @@
1
+ require "yaml/store"
2
+ require "trollop"
3
+ require "chronic"
4
+ require "timezone"
5
+ require "geocoder"
6
+ require "ruby-progressbar"
7
+
8
+ require "forecaster"
9
+
10
+ # Fetch and read data from the Global Forecast System.
11
+ module Forecaster
12
+ # Command line interface printing the forecast for a time and a location.
13
+ class CLI
14
+ include Singleton # TODO: Find how best to organize CLI class
15
+
16
+ FORECAST_FORMAT = " %-15s % 7.1f %s".freeze
17
+
18
+ def self.start(args, env)
19
+ instance.start(args, env)
20
+ end
21
+
22
+ def initialize
23
+ @store = nil
24
+ end
25
+
26
+ def start(args, env)
27
+ opts = parse(args)
28
+
29
+ configure(opts)
30
+
31
+ cache_file = File.join(Forecaster.configuration.cache_dir, "forecast.yml")
32
+ @store = YAML::Store.new(cache_file)
33
+
34
+ puts "GFS Weather Forecast"
35
+ puts
36
+
37
+ lat, lon = get_location(opts, env)
38
+
39
+ Trollop.die("Could not parse location") if lat.nil? || lon.nil?
40
+
41
+ ENV["TZ"] = get_timezone(lat, lon, env) || env["TZ"]
42
+ time = get_time(opts)
43
+ forecast = get_forecast(time, opts)
44
+ print_forecast(forecast, lat, lon)
45
+ ENV["TZ"] = env["TZ"] # Restore TZ
46
+ end
47
+
48
+ # Parse command line options
49
+ def parse(args)
50
+ opts = Trollop.options(args) do
51
+ usage "for <time> in <location>"
52
+ version "Forecaster v#{Forecaster::VERSION}"
53
+ opt :time, "Set time in any format", :type => String
54
+ opt :location, "Set location name", :type => String
55
+ opt :latitude, "Set latitude in degree", :type => Float, :short => "a"
56
+ opt :longitude, "Set longitude in degree", :type => Float, :short => "o"
57
+ opt :cache, "Set cache directory", :default => "/tmp/forecaster"
58
+ opt :debug, "Enable debug mode"
59
+ end
60
+
61
+ cmd_opts = { :time => [], :location => [] }
62
+ key = :time
63
+ args.each do |word|
64
+ case word
65
+ when "for"
66
+ key = :time
67
+ when "in"
68
+ key = :location
69
+ else
70
+ cmd_opts[key] << word
71
+ end
72
+ end
73
+ opts[:time] ||= cmd_opts[:time].join(" ")
74
+ opts[:location] ||= cmd_opts[:location].join(" ")
75
+
76
+ opts
77
+ end
78
+
79
+ # Configure gem
80
+ def configure(opts)
81
+ Forecaster.configure do |config|
82
+ config.cache_dir = opts[:cache]
83
+ config.records = {
84
+ :prate => ":PRATE:surface:",
85
+ :pres => ":PRES:surface:",
86
+ :rh => ":RH:2 m above ground:",
87
+ :tmp => ":TMP:2 m above ground:",
88
+ :ugrd => ":UGRD:10 m above ground:",
89
+ :vgrd => ":VGRD:10 m above ground:",
90
+ :tcdc => ":TCDC:entire atmosphere:"
91
+ }
92
+ end
93
+ FileUtils.mkpath(Forecaster.configuration.cache_dir)
94
+ end
95
+
96
+ # Get location
97
+ def get_location(opts, env)
98
+ if opts[:location]
99
+ @store.transaction do
100
+ if opts[:debug]
101
+ puts format("%-15s '%s'", "Geolocalizing:", opts[:location])
102
+ end
103
+
104
+ key = "geocoder:#{opts[:location]}"
105
+ lat, lon = @store[key] ||= geolocalize(opts[:location])
106
+
107
+ if opts[:debug]
108
+ if lat && lon
109
+ puts format("%-15s %s, %s", "Found:", lat, lon)
110
+ else
111
+ puts "Not found"
112
+ end
113
+ puts
114
+ end
115
+
116
+ [lat, lon]
117
+ end
118
+ elsif opts[:latitude] && opts[:longitude]
119
+ [opts[:latitude], opts[:longitude]]
120
+ else
121
+ [env["FORECAST_LATITUDE"], env["FORECAST_LONGITUDE"]]
122
+ end
123
+ end
124
+
125
+ # Get timezone
126
+ def get_timezone(lat, lon, env)
127
+ tz = nil
128
+ if env["GEONAMES_USERNAME"]
129
+ Timezone::Lookup.config(:geonames) do |config|
130
+ config.username = env["GEONAMES_USERNAME"]
131
+ end
132
+ @store.transaction do
133
+ key = "timezone:#{lat}:#{lon}"
134
+ tz = @store[key] || @store[key] = Timezone.lookup(lat, lon).name
135
+ end
136
+ end
137
+
138
+ tz
139
+ end
140
+
141
+ # Get time
142
+ def get_time(opts)
143
+ if opts[:time]
144
+ # TODO: Look for a timestamp first
145
+ time = Chronic.parse(opts[:time])
146
+ Trollop.die(:time, "could not be parsed") if time.nil?
147
+ time.utc
148
+ else
149
+ Time.now.utc
150
+ end
151
+ end
152
+
153
+ # Get forecast
154
+ def get_forecast(time, opts)
155
+ forecast = Forecast.at(time)
156
+
157
+ if opts[:debug]
158
+ puts format("%-15s %s", "Requested time:", time.localtime)
159
+ puts format("%-15s %s", "GFS run time:", forecast.run_time.localtime)
160
+ puts format("%-15s %s", "Forecast time:", forecast.time.localtime)
161
+ puts
162
+ end
163
+
164
+ unless forecast.fetched?
165
+ if opts[:debug]
166
+ puts "Downloading: '#{forecast.url}'"
167
+
168
+ puts "Reading index file..."
169
+ ranges = forecast.fetch_index
170
+
171
+ filesize = ranges.reduce(0) do |acc, range|
172
+ first, last = range.split("-").map(&:to_i)
173
+ acc + last - first
174
+ end
175
+ filesize_in_megabytes = (filesize.to_f / (1 << 20)).round(2)
176
+ puts "Length: #{filesize} (#{filesize_in_megabytes}M)"
177
+ puts
178
+
179
+ progressbar = ProgressBar.create(
180
+ :format => "%p%% [%b>%i] %r KB/s %e",
181
+ :rate_scale => lambda { |rate| rate / 1024 }
182
+ )
183
+
184
+ progress_block = lambda do |progress, total|
185
+ progressbar.total = total
186
+ progressbar.progress = progress
187
+ end
188
+
189
+ forecast.fetch_grib2(ranges, :progress_block => progress_block)
190
+
191
+ progressbar.finish
192
+ puts
193
+ else
194
+ forecast.fetch # That's a lot easier ^^
195
+ end
196
+ end
197
+
198
+ forecast
199
+ end
200
+
201
+ # Print forecast
202
+ def print_forecast(forecast, lat, lon)
203
+ forecast_time = forecast.time.localtime
204
+
205
+ puts format(" %-11s % 11s", "Date:", forecast_time.strftime("%Y-%m-%d"))
206
+ puts format(" %-11s % 11s", "Time:", forecast_time.strftime("%T"))
207
+ puts format(" %-11s % 11s", "Zone:", forecast_time.strftime("%z"))
208
+ puts format(FORECAST_FORMAT, "Latitude:", lat, "°")
209
+ puts format(FORECAST_FORMAT, "Longitude:", lon, "°")
210
+ puts
211
+
212
+ pres = forecast.read(:pres, :latitude => lat, :longitude => lon).to_f
213
+ tmp = forecast.read(:tmp, :latitude => lat, :longitude => lon).to_f
214
+ ugrd = forecast.read(:ugrd, :latitude => lat, :longitude => lon).to_f
215
+ vgrd = forecast.read(:vgrd, :latitude => lat, :longitude => lon).to_f
216
+ prate = forecast.read(:prate, :latitude => lat, :longitude => lon).to_f
217
+ rh = forecast.read(:rh, :latitude => lat, :longitude => lon).to_f
218
+ tcdc = forecast.read(:tcdc, :latitude => lat, :longitude => lon).to_f
219
+
220
+ pressure = pres / 100.0
221
+ temperature = tmp - 273.15
222
+ wind_speed = Math.sqrt(ugrd**2 + vgrd**2)
223
+ wind_direction = (270 - Math.atan2(ugrd, vgrd) * 180 / Math::PI) % 360
224
+ precipitation = prate * 3600
225
+ humidity = rh
226
+ cloud_cover = tcdc
227
+
228
+ puts format(FORECAST_FORMAT, "Pressure:", pressure, "hPa")
229
+ puts format(FORECAST_FORMAT, "Temperature:", temperature, "°C")
230
+ puts format(FORECAST_FORMAT, "Wind Speed:", wind_speed, "m/s")
231
+ puts format(FORECAST_FORMAT, "Wind Direction:", wind_direction, "°")
232
+ puts format(FORECAST_FORMAT, "Precipitation:", precipitation, "mm")
233
+ puts format(FORECAST_FORMAT, "Humidity:", humidity, "%")
234
+ puts format(FORECAST_FORMAT, "Cloud Cover:", cloud_cover, "%")
235
+ end
236
+
237
+ def geolocalize(location)
238
+ Geocoder.configure(:timeout => 10)
239
+ res = Geocoder.search(location).first
240
+ [res.latitude, res.longitude] if res
241
+ end
242
+ end
243
+ end
@@ -1,11 +1,12 @@
1
+ # Fetch and read data from the Global Forecast System.
1
2
  module Forecaster
3
+ # Configure how to fetch and read from a forecast file.
2
4
  class Configuration
3
5
  attr_accessor :server, :cache_dir, :curl_path, :wgrib2_path, :records
4
6
 
5
7
  def initialize
6
8
  @server = "http://www.ftp.ncep.noaa.gov/data/nccf/com/gfs/prod"
7
9
  @cache_dir = "/tmp/forecaster"
8
- @curl_path = "curl"
9
10
  @wgrib2_path = "wgrib2"
10
11
 
11
12
  # See: http://www.nco.ncep.noaa.gov/pmb/products/gfs/gfs_upgrade/gfs.t06z.pgrb2.0p25.f006.shtml
@@ -1,33 +1,68 @@
1
1
  require "fileutils"
2
+ require "excon"
2
3
 
4
+ # Fetch and read data from the Global Forecast System.
3
5
  module Forecaster
6
+ # Fetch and read a specific forecast from a GFS run.
7
+ #
8
+ # See: http://www.nco.ncep.noaa.gov/pmb/products/gfs/
4
9
  class Forecast
5
- def initialize(year, month, day, hour_of_run, hour_of_forecast)
10
+ def self.last_run_at
11
+ # There is a new GFS run every 6 hours starting at midnight UTC, and it
12
+ # takes approximately 3 to 5 hours before a run is available online, so
13
+ # to be on the safe side we return the previous one.
14
+ now = Time.now.utc
15
+ run = Time.new(now.year, now.month, now.day, (now.hour / 6) * 6)
16
+
17
+ run - 6 * 3600
18
+ end
19
+
20
+ def self.at(time)
21
+ # There is a forecast every 3 hours after a run for 384 hours.
22
+ t = time.utc
23
+ fct = Time.new(t.year, t.month, t.day, (t.hour / 3) * 3)
24
+ run = Time.new(t.year, t.month, t.day, (t.hour / 6) * 6)
25
+ run -= 6 * 3600 if run == fct
26
+
27
+ last_run = Forecast.last_run_at
28
+ run = last_run if run > last_run
29
+
30
+ fct_hour = (fct - run) / 3600
31
+
32
+ raise "Time too far in the future" if fct_hour > 384
33
+
34
+ Forecast.new(run.year, run.month, run.day, run.hour, fct_hour)
35
+ end
36
+
37
+ def initialize(year, month, day, run_hour, fct_hour)
6
38
  @year = year
7
39
  @month = month
8
40
  @day = day
9
- @hour_of_run = hour_of_run
10
- @hour_of_forecast = hour_of_forecast
41
+ @run_hour = run_hour
42
+ @fct_hour = fct_hour
43
+ end
44
+
45
+ def run_time
46
+ Time.utc(@year, @month, @day, @run_hour)
47
+ end
48
+
49
+ def time
50
+ run_time + @fct_hour * 3600
11
51
  end
12
52
 
13
53
  def dirname
14
- subdir = "%04d%02d%02d%02d" % [
15
- @year, @month, @day, @hour_of_run
16
- ]
54
+ subdir = format("%04d%02d%02d%02d", @year, @month, @day, @run_hour)
17
55
  File.join(Forecaster.configuration.cache_dir, subdir)
18
56
  end
19
57
 
20
58
  def filename
21
- "gfs.t%02dz.pgrb2.0p25.f%03d" % [
22
- @hour_of_run, @hour_of_forecast
23
- ]
59
+ format("gfs.t%02dz.pgrb2.0p25.f%03d", @run_hour, @fct_hour)
24
60
  end
25
61
 
26
62
  def url
27
63
  server = Forecaster.configuration.server
28
- "%s/gfs.%04d%02d%02d%02d/%s" % [
29
- server, @year, @month, @day, @hour_of_run, filename
30
- ]
64
+ subdir = format("gfs.%04d%02d%02d%02d", @year, @month, @day, @run_hour)
65
+ format("%s/%s/%s", server, subdir, filename)
31
66
  end
32
67
 
33
68
  def fetched?
@@ -38,44 +73,54 @@ module Forecaster
38
73
  # But only the parts of the file containing the fields defined in
39
74
  # the configuration will be downloaded.
40
75
  def fetch
41
- return self if fetched?
42
-
43
- curl = Forecaster.configuration.curl_path
44
-
45
- FileUtils.mkpath(File.join(dirname))
46
-
47
- path = File.join(dirname, filename)
76
+ return if fetched?
77
+ ranges = fetch_index
78
+ fetch_grib2(ranges)
79
+ end
48
80
 
49
- # puts "Downloading '#{url}.idx' ..."
50
- cmd = "#{curl} -fsS -o #{path}.idx #{url}.idx"
51
- raise "Download of '#{url}.idx' failed" unless system(cmd)
81
+ def fetch_index
82
+ begin
83
+ res = Excon.get("#{url}.idx")
84
+ rescue Excon::Errors::Error
85
+ raise "Download of '#{url}.idx' failed"
86
+ end
52
87
 
53
- lines = IO.readlines("#{path}.idx")
54
- n = lines.count
55
- ranges = lines.each_index.reduce([]) do |r, i|
88
+ lines = res.body.lines
89
+ lines.each_index.reduce([]) do |r, i|
56
90
  records = Forecaster.configuration.records
57
91
  if records.values.any? { |record| lines[i].include?(record) }
58
92
  first = lines[i].split(":")[1].to_i
59
93
  last = ""
60
94
 
61
95
  j = i
62
- while (j += 1) < n
96
+ while (j += 1) < lines.count
63
97
  last = lines[j].split(":")[1].to_i - 1
64
98
  break if last != first - 1
65
99
  end
66
100
 
67
- r << "#{first}-#{last}" # cURL syntax for a range
101
+ r << "#{first}-#{last}" # Range header syntax
68
102
  else
69
103
  r
70
104
  end
71
105
  end
72
- system("rm #{path}.idx")
106
+ end
73
107
 
74
- # puts "Downloading '#{url}' ..."
75
- cmd = "#{curl} -fsS -r #{ranges.join(",")} -o #{path} #{url}"
76
- raise "Download of '#{url}' failed" unless system(cmd)
108
+ def fetch_grib2(ranges, progress_block: nil)
109
+ FileUtils.mkpath(dirname)
110
+ path = File.join(dirname, filename)
77
111
 
78
- self
112
+ streamer = lambda do |chunk, remaining, total|
113
+ File.open(path, "ab") { |f| f.write(chunk) }
114
+ progress_block.call(total - remaining, total) if progress_block
115
+ end
116
+
117
+ headers = { "Range" => "bytes=#{ranges.join(',')}" }
118
+ begin
119
+ Excon.get(url, :headers => headers, :response_block => streamer)
120
+ rescue Excon::Errors::Error => e
121
+ File.delete(path)
122
+ raise "Download of '#{url}' failed: #{e}"
123
+ end
79
124
  end
80
125
 
81
126
  def read(field, latitude: 0.0, longitude: 0.0)
@@ -83,7 +128,7 @@ module Forecaster
83
128
  record = Forecaster.configuration.records[field]
84
129
  path = File.join(dirname, filename)
85
130
 
86
- raise "'#{path}' not found" unless File.exists?(path)
131
+ raise "'#{path}' not found" unless File.exist?(path)
87
132
 
88
133
  coords = "#{longitude} #{latitude}"
89
134
  output = `#{wgrib2} #{path} -lon #{coords} -match "#{record}"`
@@ -0,0 +1,4 @@
1
+ # Fetch and read data from the Global Forecast System.
2
+ module Forecaster
3
+ VERSION = "0.1.0".freeze
4
+ end
data/lib/forecaster.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require "forecaster/configuration"
2
2
  require "forecaster/forecast"
3
+ require "forecaster/version"
4
+ require "forecaster/cli"
3
5
 
6
+ # Fetch and read data from the Global Forecast System.
4
7
  module Forecaster
5
8
  class << self
6
9
  attr_accessor :configuration
@@ -12,6 +15,13 @@ module Forecaster
12
15
  end
13
16
 
14
17
  def self.fetch(year, month, day, hour_of_run, hour_of_forecast)
15
- Forecaster::Forecast.new(year, month, day, hour_of_run, hour_of_forecast).fetch
18
+ y = year
19
+ m = month
20
+ d = day
21
+ c = hour_of_run
22
+ h = hour_of_forecast
23
+ forecast = Forecaster::Forecast.new(y, m, d, c, h)
24
+ forecast.fetch
25
+ forecast
16
26
  end
17
27
  end
metadata CHANGED
@@ -1,24 +1,148 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forecaster
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vincent Ollivier
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-06-09 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2016-06-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: excon
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.49'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.49.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '0.49'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.49.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: trollop
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.1'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 2.1.0
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '2.1'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 2.1.0
53
+ - !ruby/object:Gem::Dependency
54
+ name: chronic
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.10'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 0.10.0
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '0.10'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 0.10.0
73
+ - !ruby/object:Gem::Dependency
74
+ name: timezone
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '0.99'
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 0.99.0
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.99'
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 0.99.0
93
+ - !ruby/object:Gem::Dependency
94
+ name: geocoder
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '1.3'
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.3.0
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.3'
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 1.3.0
113
+ - !ruby/object:Gem::Dependency
114
+ name: ruby-progressbar
115
+ requirement: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - "~>"
118
+ - !ruby/object:Gem::Version
119
+ version: '1.8'
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 1.8.0
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '1.8'
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: 1.8.0
13
133
  description: Wrapper around curl and wgrib2 to fetch and read GFS data
14
134
  email: v@vinc.cc
15
- executables: []
135
+ executables:
136
+ - forecast
16
137
  extensions: []
17
138
  extra_rdoc_files: []
18
139
  files:
140
+ - bin/forecast
19
141
  - lib/forecaster.rb
142
+ - lib/forecaster/cli.rb
20
143
  - lib/forecaster/configuration.rb
21
144
  - lib/forecaster/forecast.rb
145
+ - lib/forecaster/version.rb
22
146
  homepage: https://github.com/vinc/forecaster
23
147
  licenses:
24
148
  - MIT