weather-sage 0.1.0 → 0.1.1

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 (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