forecaster 0.0.2 → 0.1.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.
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