weather-sage 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/weather-sage/cache-entry.rb +13 -0
  3. data/lib/weather-sage/cache.rb +79 -0
  4. data/lib/weather-sage/census/geocoder.rb +48 -0
  5. data/lib/weather-sage/census/match.rb +19 -0
  6. data/lib/weather-sage/census.rb +7 -0
  7. data/lib/weather-sage/cli/commands/base-forecast.rb +70 -0
  8. data/lib/weather-sage/cli/commands/command.rb +42 -0
  9. data/lib/weather-sage/cli/commands/forecast.rb +28 -0
  10. data/lib/weather-sage/cli/commands/geocode.rb +51 -0
  11. data/lib/weather-sage/cli/commands/help.rb +82 -0
  12. data/lib/weather-sage/cli/commands/hourly.rb +28 -0
  13. data/lib/weather-sage/cli/commands/now.rb +87 -0
  14. data/lib/weather-sage/cli/commands/stations.rb +67 -0
  15. data/lib/weather-sage/cli/commands.rb +15 -0
  16. data/lib/weather-sage/cli/env/cache.rb +38 -0
  17. data/lib/weather-sage/cli/env/context.rb +29 -0
  18. data/lib/weather-sage/cli/env/env.rb +35 -0
  19. data/lib/weather-sage/cli/env/log.rb +41 -0
  20. data/lib/weather-sage/cli/env/vars.rb +20 -0
  21. data/lib/weather-sage/cli/env.rb +14 -0
  22. data/lib/weather-sage/cli/forecast.rb +70 -0
  23. data/lib/weather-sage/cli/help.rb +68 -0
  24. data/lib/weather-sage/cli.rb +29 -0
  25. data/lib/weather-sage/context.rb +13 -0
  26. data/lib/weather-sage/http/cache.rb +72 -0
  27. data/lib/weather-sage/http/error.rb +15 -0
  28. data/lib/weather-sage/http/fetcher.rb +79 -0
  29. data/lib/weather-sage/http/parser.rb +46 -0
  30. data/lib/weather-sage/http.rb +9 -0
  31. data/lib/weather-sage/weather/base-object.rb +36 -0
  32. data/lib/weather-sage/weather/forecast.rb +47 -0
  33. data/lib/weather-sage/weather/observation.rb +33 -0
  34. data/lib/weather-sage/weather/period.rb +44 -0
  35. data/lib/weather-sage/weather/point.rb +71 -0
  36. data/lib/weather-sage/weather/station.rb +43 -0
  37. data/lib/weather-sage/weather.rb +11 -0
  38. data/lib/weather-sage.rb +1 -1
  39. metadata +39 -3
  40. data/test/make-csvs.sh +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 390dc567cf5a2696830a36bd2a6a27020a2310721a95f69530ccbd0688c06d28
4
- data.tar.gz: 9fc5165689410c41763750e06b5590452294f2588880cb2f027dbe98191e0be6
3
+ metadata.gz: 2d8a169b0525d21c350826048cf01cc233741f1c1d897f88a1f2f05787aa38ad
4
+ data.tar.gz: 440a697b31b7e806c82afd0e572e313d1fb9e033a67cda205ba1c00be301bc6d
5
5
  SHA512:
6
- metadata.gz: b23545938bb233191fb042f1cb7d45c098332421dbfb53c6fae126f5a3ef8f6543d75c16d4537582222e519e7da320fc11abbcaadaeed0f7fd4fcb31f83cdefc
7
- data.tar.gz: 1541a3ef620d4667e45fe513f95f61e891ff259ab0309c4286ca709498eca8bca2972d9ede59e0587bd51dbacb9403c9ebdff1d491af33320f58400fae619c9f
6
+ metadata.gz: 80305e078b4be11c351509cb1aaef299ae47fb8db851f35a1ed1651319f2ff89b4dd3246c8a5b2a23ad01a87adddcb060b4353fc9001fbbcc0c997bac4df306f
7
+ data.tar.gz: b1f752c2f412cf374b85caec3563801d299ec17e94102276f71da9ac5be2fea5fe895c8329bbc9e2ca1c1ffd5f0fb579959282246a33bec97214e195e60ba94c
@@ -0,0 +1,13 @@
1
+ require 'pstore'
2
+
3
+ #
4
+ # Cache entry.
5
+ #
6
+ class WeatherSage::CacheEntry < ::Struct.new(:expires, :value)
7
+ #
8
+ # Is this cache entry valid?
9
+ #
10
+ def valid?
11
+ expires ? (Time.now.to_i < expires) : true
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ require 'pstore'
2
+
3
+ #
4
+ # Minimal cache implementation.
5
+ #
6
+ class WeatherSage::Cache
7
+ #
8
+ # Create a new Cache instance bound to file at path +path+.
9
+ #
10
+ def initialize(path)
11
+ @pstore = ::PStore.new(path)
12
+ end
13
+
14
+ #
15
+ # Returns true if the key exists and is it still valid.
16
+ #
17
+ def key?(key)
18
+ @pstore.transaction(true) do
19
+ return false unless entry = @pstore[key]
20
+ entry.valid?
21
+ end
22
+ end
23
+
24
+ #
25
+ # Set entry in cache with key +key+ to value +val+.
26
+ #
27
+ # An optional timeout (in seconds) may be provided with +timout+.
28
+ #
29
+ def set(key, val, timeout = nil)
30
+ @pstore.transaction do
31
+ # calculate expiration time
32
+ expires = timeout ? (Time.now.to_i + timeout) : nil
33
+
34
+ # save entry
35
+ @pstore[key] = ::WeatherSage::CacheEntry.new(expires, val)
36
+
37
+ # purge any expired entries
38
+ flush
39
+ end
40
+
41
+ # return value
42
+ val
43
+ end
44
+
45
+ #
46
+ # Get entry in cache with key +key+.
47
+ #
48
+ # Returns *nil* if no such entry exists.
49
+ #
50
+ def get(key)
51
+ @pstore.transaction(true) do
52
+ return nil unless entry = @pstore[key]
53
+ entry.valid? ? entry.value : nil
54
+ end
55
+ end
56
+
57
+ #
58
+ # Delete entry in cache with key +key+.
59
+ #
60
+ def delete(key)
61
+ @pstore.transaction do
62
+ @pstore.delete(key)
63
+ end
64
+ end
65
+
66
+ alias :[] :get
67
+ alias :[]= :set
68
+
69
+ private
70
+
71
+ #
72
+ # Purge expired entries.
73
+ #
74
+ def flush
75
+ @pstore.roots.each do |key|
76
+ @pstore.delete(key) unless @pstore[key].valid?
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,48 @@
1
+ #
2
+ # Wrapper around Census geocoder API.
3
+ #
4
+ class WeatherSage::Census::Geocoder
5
+ #
6
+ # URL endpoint for Census Geocoder API.
7
+ #
8
+ URL = 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress'
9
+
10
+ #
11
+ # Static parameters for geocoder requests.
12
+ #
13
+ # Source:
14
+ # https://geocoding.geo.census.gov/geocoder/Geocoding_Services_API.pdf
15
+ PARAMS = {
16
+ returntype: 'locations',
17
+ benchmark: 'Public_AR_Current',
18
+ format: 'json',
19
+ }.freeze
20
+
21
+ #
22
+ # Create new Geocoder instance.
23
+ #
24
+ def initialize(ctx)
25
+ @ctx = ctx
26
+ end
27
+
28
+ #
29
+ # Geocode given address string +s+ and return an array of Match
30
+ # objects.
31
+ #
32
+ def run(s)
33
+ # exec request
34
+ data = @ctx.cache.get(URL, PARAMS.merge({
35
+ address: s,
36
+ }))
37
+
38
+ # log data
39
+ @ctx.log.debug('Geocoder#run') do
40
+ 'data = %p' % [data]
41
+ end
42
+
43
+ # map matches and return result
44
+ data['result']['addressMatches'].map { |row|
45
+ ::WeatherSage::Census::Match.new(@ctx, row)
46
+ }
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ #
2
+ # Matching address returned by Geocoder.
3
+ #
4
+ class WeatherSage::Census::Match
5
+ attr :point, :address, :data
6
+
7
+ #
8
+ # Create a new Match object.
9
+ #
10
+ def initialize(ctx, data)
11
+ # get coordinates
12
+ x, y = %w{x y}.map { |k| data['coordinates'][k] }
13
+
14
+ # cache data, address, and point
15
+ @data = data.freeze
16
+ @address = @data['matchedAddress']
17
+ @point = ::WeatherSage::Weather::Point.new(ctx, x, y).freeze
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ #
2
+ # Thin wrapper around Census geocoder.
3
+ #
4
+ module WeatherSage::Census
5
+ autoload :Match, File.join(__dir__, 'census', 'match.rb')
6
+ autoload :Geocoder, File.join(__dir__, 'census', 'geocoder.rb')
7
+ end
@@ -0,0 +1,70 @@
1
+ #
2
+ # Base forecast command.
3
+ #
4
+ # Used by ForecastCommand and HourlyCommand.
5
+ #
6
+ class WeatherSage::CLI::Commands::BaseForecastCommand < WeatherSage::CLI::Commands::Command
7
+ #
8
+ # Do not invoke this method directly; subclass this class and
9
+ # override the +run+ method.
10
+ #
11
+ def initialize(ctx, app)
12
+ super(ctx, app)
13
+ @forecast_method = self.class.const_get(:FORECAST_METHOD)
14
+ end
15
+
16
+ #
17
+ # Run command.
18
+ #
19
+ def run(args)
20
+ # get mode and args
21
+ mode, args = parse_args(args)
22
+
23
+ CSV(STDOUT) do |csv|
24
+ # write column names
25
+ csv << columns(mode).map { |col| col[:name] }
26
+
27
+ args.each do |arg|
28
+ # geocode argument, get first point
29
+ if pt = geocode(arg).first
30
+ # walk forecast periods
31
+ pt.point.send(@forecast_method).periods.each do |p|
32
+ csv << make_row(mode, arg, p)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ #
42
+ # Extract mode and args from command-line arguments.
43
+ #
44
+ def parse_args(args)
45
+ case args.first
46
+ when /^-f|--full$/
47
+ [:full, args[1 .. -1]]
48
+ when /^-b|--brief$/
49
+ [:brief, args[1 .. -1]]
50
+ else
51
+ [:brief, args]
52
+ end
53
+ end
54
+
55
+ #
56
+ # Convert forecast period to CSV row.
57
+ #
58
+ def make_row(mode, address, p)
59
+ [address] + columns(mode).select { |col|
60
+ col[:prop]
61
+ }.map { |col| p.data[col[:prop]] }
62
+ end
63
+
64
+ #
65
+ # Get columns for given mode.
66
+ #
67
+ def columns(mode)
68
+ ::WeatherSage::CLI::Forecast::columns(@forecast_method, mode)
69
+ end
70
+ end
@@ -0,0 +1,42 @@
1
+ #
2
+ # Base class for command-line commands.
3
+ #
4
+ # You should subclass this class to create new commands.
5
+ #
6
+ class WeatherSage::CLI::Commands::Command
7
+ #
8
+ # Do not invoke this method directly; subclass Command and
9
+ # override the +run+ method.
10
+ #
11
+ def initialize(ctx, app)
12
+ @ctx, @app = ctx, app
13
+
14
+ # create geocoder
15
+ @geocoder = ::WeatherSage::Census::Geocoder.new(ctx)
16
+ end
17
+
18
+ #
19
+ # Run command.
20
+ #
21
+ def self.run(ctx, app, args)
22
+ new(ctx, app).run(args)
23
+ end
24
+
25
+ #
26
+ # Virtual method. You need to subclass and override this method
27
+ # to add a new command.
28
+ #
29
+ def run(args)
30
+ raise "not implemented"
31
+ end
32
+
33
+ protected
34
+
35
+ #
36
+ # Geocode given street address and return array of
37
+ # Census::Geocode::Match results.
38
+ #
39
+ def geocode(s)
40
+ @geocoder.run(s)
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ #
2
+ # Implementation of *forecast* command.
3
+ #
4
+ class WeatherSage::CLI::Commands::ForecastCommand < WeatherSage::CLI::Commands::BaseForecastCommand
5
+ #
6
+ # Help for this command.
7
+ #
8
+ # Used by the *help* command.
9
+ #
10
+ HELP = {
11
+ line: '
12
+ Get weather forecast for address.
13
+ '.strip,
14
+
15
+ full: [
16
+ 'Get weather forecast for address.',
17
+ '',
18
+ 'Use --full to see additional columns.',
19
+ ].join("\n")
20
+ }.freeze
21
+
22
+ #
23
+ # Forecast method.
24
+ #
25
+ # Used by BaseForecastCommand to call correct forecast method.
26
+ #
27
+ FORECAST_METHOD = :forecast
28
+ end
@@ -0,0 +1,51 @@
1
+ module WeatherSage
2
+ module CLI
3
+ module Commands
4
+ #
5
+ # Implementation of *geocode* command.
6
+ #
7
+ class GeocodeCommand < Command
8
+ #
9
+ # Help for this command.
10
+ #
11
+ # Used by the *help* command.
12
+ #
13
+ HELP = {
14
+ line: '
15
+ Geocode address.
16
+ '.strip,
17
+
18
+ full: [
19
+ 'Geocode address.',
20
+ ].join("\n"),
21
+ }.freeze
22
+
23
+ #
24
+ # CSV columns.
25
+ #
26
+ CSV_COLS = %w{input_address match_address x y}
27
+
28
+ #
29
+ # Entry point for *geocode* command-line command.
30
+ #
31
+ def run(args)
32
+ # create geocoder
33
+ geocoder = Census::Geocoder.new(@ctx)
34
+
35
+ CSV(STDOUT) do |csv|
36
+ # write column headers
37
+ csv << CSV_COLS
38
+
39
+ # iterate command-line arguments and geocode each one
40
+ args.each do |arg|
41
+ # geocode argument and write results to output CSV
42
+ geocoder.run(arg).each do |row|
43
+ csv << [arg, row.address, row.point.x, row.point.y]
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ #
2
+ # Implementation of *help* command-line command.
3
+ #
4
+ class WeatherSage::CLI::Commands::HelpCommand < ::WeatherSage::CLI::Commands::Command
5
+ #
6
+ # Help for this command.
7
+ #
8
+ # Used by the *help* command.
9
+ #
10
+ HELP = {
11
+ line: '
12
+ List commands.
13
+ '.strip,
14
+
15
+ full: [
16
+ 'List commands. Use "help <command>" to show details for a',
17
+ 'specific command.',
18
+ ].join("\n"),
19
+ }.freeze
20
+
21
+ #
22
+ # Entry point for *help* command-line command.
23
+ #
24
+ def run(args)
25
+ if args.size > 0
26
+ # show details of given commands
27
+ show_details(args)
28
+ else
29
+ # print full list of commands
30
+ list_commands
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ #
37
+ # Print full list of commands.
38
+ #
39
+ def list_commands
40
+ puts template(:all, {
41
+ app: File.basename(@app),
42
+
43
+ cmds: ::WeatherSage::CLI::Help::COMMANDS.values.map { |cmd|
44
+ template(:cmd, cmd)
45
+ }.join("\n"),
46
+
47
+ envs: ::WeatherSage::CLI::Env::VARS.map { |row|
48
+ template(:env, row)
49
+ }.join("\n\n"),
50
+ })
51
+ end
52
+
53
+ #
54
+ # Print details of given commands.
55
+ #
56
+ def show_details(args)
57
+ # get a list of unknown commands
58
+ unknown_cmds = args.select do |arg|
59
+ !WeatherSage::CLI::Help::COMMANDS.key?(arg)
60
+ end
61
+
62
+ if unknown_cmds.size > 0
63
+ # print list of unknown commands and exit
64
+ puts 'Unknown commands: %s' % [unknown_cmds.join(', ')]
65
+ exit -1
66
+ end
67
+
68
+ # print detailed help for each argument
69
+ puts args.map { |arg| template(:one, commands[arg]) }
70
+ end
71
+
72
+ #
73
+ # Expand template.
74
+ #
75
+ def template(key, args = {})
76
+ unless WeatherSage::CLI::Help::TEMPLATES.key?(key)
77
+ raise "unknown template: #{key}"
78
+ end
79
+
80
+ WeatherSage::CLI::Help::TEMPLATES[key] % args
81
+ end
82
+ end
@@ -0,0 +1,28 @@
1
+ #
2
+ # Implementation of *hourly* command.
3
+ #
4
+ class WeatherSage::CLI::Commands::HourlyCommand < WeatherSage::CLI::Commands::BaseForecastCommand
5
+ #
6
+ # Help for this command.
7
+ #
8
+ # Used by the *help* command.
9
+ #
10
+ HELP = {
11
+ line: '
12
+ Get hourly weather forecast for address.
13
+ '.strip,
14
+
15
+ full: [
16
+ 'Get hourly weather forecast for address.',
17
+ '',
18
+ 'Use --full to see additional columns.',
19
+ ].join("\n")
20
+ }.freeze
21
+
22
+ #
23
+ # Forecast method.
24
+ #
25
+ # Used by BaseForecastCommand to call correct forecast method.
26
+ #
27
+ FORECAST_METHOD = :hourly_forecast
28
+ end
@@ -0,0 +1,87 @@
1
+ #
2
+ # Implementation of *now* command-line command.
3
+ #
4
+ class WeatherSage::CLI::Commands::NowCommand < WeatherSage::CLI::Commands::Command
5
+ #
6
+ # Help for this command.
7
+ #
8
+ # Used by the *help* command.
9
+ #
10
+ HELP = {
11
+ line: '
12
+ Get current weather from station closest to address.
13
+ '.strip,
14
+
15
+ full: [
16
+ 'Get current weather at from station closest to address.',
17
+ ].join("\n")
18
+ }.freeze
19
+
20
+ #
21
+ # CSV column names.
22
+ #
23
+ COL_NAMES = %w{
24
+ address
25
+ name
26
+ type
27
+ value
28
+ unit
29
+ quality_control
30
+ }.freeze
31
+
32
+ #
33
+ # Run *now* command.
34
+ #
35
+ def run(args)
36
+ CSV(STDOUT) do |csv|
37
+ # write column names
38
+ csv << COL_NAMES
39
+
40
+ # iterate over command-line arguments and write each one
41
+ args.each do |arg|
42
+ # geocode to first point
43
+ if pt = geocode(arg).first
44
+ # get first station
45
+ if st = pt.point.stations.first
46
+ # get latest observation data
47
+ data = st.latest_observations
48
+
49
+ # write observations
50
+ make_rows(arg, data) do |row|
51
+ csv << row
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ #
62
+ # Map observation properties in result to CSV rows and yield each
63
+ # row.
64
+ #
65
+ # FIXME: this is a bit of a hack.
66
+ #
67
+ def make_rows(address, data, &block)
68
+ ::WeatherSage::Weather::Observation::PROPERTIES.each do |key, type|
69
+ # get observation
70
+ if v = data[key.to_s]
71
+ # map observation to row, then yield row
72
+ block.call(case type
73
+ when :text, :time, :url
74
+ [address, key, type, v]
75
+ when :value
76
+ [address, key, type, v['value'], v['unitCode'], v['qualityControl']]
77
+ when :cloud
78
+ # hack: only show data for first cloud layer
79
+ base = v.first['base']
80
+ [address, key, type, base['value'], base['unitCode']]
81
+ else
82
+ raise "unkown type: #{type}"
83
+ end)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,67 @@
1
+ module WeatherSage
2
+ module CLI
3
+ module Commands
4
+ #
5
+ # Implementation of *stations* command.
6
+ #
7
+ class StationsCommand < Command
8
+ #
9
+ # Help for this command.
10
+ #
11
+ # Used by the *help* command.
12
+ #
13
+ HELP = {
14
+ line: '
15
+ List weather stations near address.
16
+ '.strip,
17
+
18
+ full: [
19
+ 'List weather stations near address.',
20
+ ].join("\n")
21
+ }.freeze
22
+
23
+ #
24
+ # CSV column names.
25
+ #
26
+ COL_NAMES = %w{
27
+ address
28
+ station_id
29
+ station_name
30
+ x
31
+ y
32
+ elevation
33
+ time_zone
34
+ }.freeze
35
+
36
+ #
37
+ # Run *stations* command.
38
+ #
39
+ def run(args)
40
+ CSV(STDOUT) do |csv|
41
+ # write column names
42
+ csv << COL_NAMES
43
+
44
+ args.each do |arg|
45
+ # geocode argument, get first point
46
+ if pt = geocode(arg).first
47
+ # walk stations
48
+ pt.point.stations.each do |s|
49
+ csv << make_row(arg, s)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ #
59
+ # Convert station to CSV row.
60
+ #
61
+ def make_row(address, s)
62
+ [address, s.id, s.name, s.x, s.y, s.elevation, s.time_zone]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ #
2
+ # Namespace for command-line commands.
3
+ #
4
+ module WeatherSage::CLI::Commands
5
+ LIB_DIR = File.join(__dir__, 'commands') # :nodoc:
6
+
7
+ autoload :Command, File.join(LIB_DIR, 'command.rb')
8
+ autoload :HelpCommand, File.join(LIB_DIR, 'help.rb')
9
+ autoload :GeocodeCommand, File.join(LIB_DIR, 'geocode.rb')
10
+ autoload :NowCommand, File.join(LIB_DIR, 'now.rb')
11
+ autoload :StationsCommand, File.join(LIB_DIR, 'stations.rb')
12
+ autoload :BaseForecastCommand, File.join(LIB_DIR, 'base-forecast.rb')
13
+ autoload :ForecastCommand, File.join(LIB_DIR, 'forecast.rb')
14
+ autoload :HourlyCommand, File.join(LIB_DIR, 'hourly.rb')
15
+ end
@@ -0,0 +1,38 @@
1
+ require 'fileutils'
2
+
3
+ #
4
+ # Create HTTP::Cache fron environment variables.
5
+ #
6
+ class WeatherSage::CLI::Env::Cache < ::WeatherSage::HTTP::Cache
7
+ #
8
+ # Default cache path.
9
+ #
10
+ DEFAULT_PATH = '~/.config/weather-sage/http-cache.pstore'
11
+
12
+ #
13
+ # Create HTTP::Cache fron environment variables.
14
+ #
15
+ # Uses the following environment variables:
16
+ #
17
+ # - WEATHER_SAGE_CACHE_PATH: Path to HTTP cache file. Defaults to
18
+ # "~/.config/weather-sage/http-cache.pstore".
19
+ #
20
+ def initialize(env, log)
21
+ # get cache path
22
+ unless path = env.get('CACHE_PATH')
23
+ # use default cache path
24
+ path = File.expand_path(DEFAULT_PATH)
25
+
26
+ # create parent directories (if necessary)
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ end
29
+
30
+ # log cache path
31
+ log.info('Env::Cache#initialize') do
32
+ 'path = %p' % [path]
33
+ end
34
+
35
+ # return cache instance
36
+ super(path, log)
37
+ end
38
+ end