vinc/forecaster

View on GitHub
lib/forecaster/cli.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require "yaml/store"
require "optimist"
require "chronic"
require "timezone"
require "geocoder"
require "ruby-progressbar"
require "rainbow"

require "forecaster"

# Fetch and read data from the Global Forecast System.
module Forecaster
  # Command line interface printing the forecast for a time and a location.
  class CLI
    include Singleton # TODO: Find how best to organize CLI class

    def self.start(args, env)
      instance.start(args, env)
    end

    def initialize
      @store = nil
    end

    def start(args, env)
      opts = parse(args)

      configure(opts)

      cache_file = File.join(Forecaster.configuration.cache_dir, "forecast.yml")
      @store = YAML::Store.new(cache_file)

      puts Rainbow("GFS Weather Forecast").bright
      puts

      lat, lon = get_location(opts, env)

      Optimist.die("Could not parse location") if lat.nil? || lon.nil?

      ENV["TZ"] = get_timezone(lat, lon, env) || env["TZ"]
      time = get_time(opts)
      forecast = get_forecast(time, opts)
      print_forecast(forecast, lat, lon)
      ENV["TZ"] = env["TZ"] # Restore TZ
    end

    # Parse command line options
    def parse(args)
      opts = Optimist.options(args) do
        version         "Forecaster v#{Forecaster::VERSION}"
        banner          "Usage: forecast for <time> in <location>"
        banner          ""
        banner          "Options:"
        opt :time,      "Set time in any format",  :type => String
        opt :location,  "Set location name",       :type => String
        opt :latitude,  "Set latitude in degree",  :type => Float, :short => "a"
        opt :longitude, "Set longitude in degree", :type => Float, :short => "o"
        opt :cache,     "Set cache directory",     :default => "/tmp/forecaster"
        opt :debug,     "Enable debug mode"
      end

      cmd_opts = { :time => [], :location => [] }
      key = :time
      args.each do |word|
        case word
        when "for"
          key = :time
        when "in"
          key = :location
        else
          cmd_opts[key] << word
        end
      end
      opts[:time] ||= cmd_opts[:time].join(" ")
      opts[:location] ||= cmd_opts[:location].join(" ")

      opts
    end

    # Configure gem
    def configure(opts)
      Forecaster.configure do |config|
        config.cache_dir = opts[:cache]
        config.records = {
          :prate => ":PRATE:surface:",
          :pres  => ":PRES:surface:",
          :rh    => ":RH:2 m above ground:",
          :tmp   => ":TMP:2 m above ground:",
          :ugrd  => ":UGRD:10 m above ground:",
          :vgrd  => ":VGRD:10 m above ground:",
          :tcdc  => ":TCDC:entire atmosphere:"
        }
      end
      FileUtils.mkpath(Forecaster.configuration.cache_dir)
    end

    # Get location
    def get_location(opts, env)
      if (opts[:location] || "").length > 0
        @store.transaction do
          if opts[:debug]
            putf_debug("Geolocalizing", opts[:location], "'%s'")
          end

          key = "geocoder:#{opts[:location]}"
          lat, lon = @store[key] ||= geolocalize(opts[:location])

          if opts[:debug]
            if lat && lon
              putf_debug("Location", lat, "%05.2f, %05.2f", optional: lon)
            else
              puts Rainbow("Location not found").red
            end
            puts
          end

          [lat, lon]
        end
      elsif opts[:latitude] && opts[:longitude]
        [opts[:latitude], opts[:longitude]]
      else
        [env["FORECAST_LATITUDE"], env["FORECAST_LONGITUDE"]]
      end
    end

    # Get timezone
    def get_timezone(lat, lon, env)
      tz = nil
      if env["GEONAMES_USERNAME"]
        Timezone::Lookup.config(:geonames) do |config|
          config.username = env["GEONAMES_USERNAME"]
        end
        @store.transaction do
          key = "timezone:#{lat}:#{lon}"
          tz = @store[key] || @store[key] = Timezone.lookup(lat, lon).name
        end
      end

      tz
    end

    # Get time
    def get_time(opts)
      if opts[:time]
        # TODO: Look for a timestamp first
        time = Chronic.parse(opts[:time])
        Optimist.die(:time, "could not be parsed") if time.nil?
        time.utc
      else
        Time.now.utc
      end
    end

    # Get forecast
    def get_forecast(time, opts)
      forecast = Forecast.at(time)

      if opts[:debug]
        putf_debug("Requested time", time.localtime,              "%s")
        putf_debug("GFS run time",   forecast.run_time.localtime, "%s")
        putf_debug("Forecast time",  forecast.time.localtime,     "%s")
        puts
      end

      unless forecast.fetched?
        if opts[:debug]
          putf_debug("Downloading", forecast.url, "'%s'")

          putf_debug("Reading index file",  "", "")
          records = Forecaster.configuration.records.values
          ranges = forecast.fetch_ranges
          ranges = records.map { |k| ranges[k] } # Filter ranges

          filesize = ranges.reduce(0) do |acc, (first, last)|
            acc + last - first # FIXME: `last == nil` on last range of index file
          end
          n = (filesize.to_f / (1 << 20)).round(2)
          putf_debug("Length", filesize, "%d (%.2fM)", optional: n)
          puts

          progressbar = ProgressBar.create(
            :format => "%p%% [%b>%i] %r KB/s %e",
            :rate_scale => lambda { |rate| rate / 1024 }
          )

          progress_block = lambda do |progress, total|
            progressbar.total = total
            progressbar.progress = progress
          end

          forecast.fetch_grib2(ranges, :progress_block => progress_block)

          progressbar.finish
          puts
        else
          forecast.fetch # That's a lot easier ^^
        end
      end

      forecast
    end

    # Print forecast
    def print_forecast(forecast, lat, lon)
      putf("Date", forecast.time.localtime.strftime("%Y-%m-%d"), "%s")
      putf("Time", forecast.time.localtime.strftime("%T"),       "%s")
      putf("Zone", forecast.time.localtime.strftime("%z"),       "%s")

      # Coordinates rounded to the precision of the GFS model
      putf("Latitude",  (lat / 0.25).round / 4.0, "%05.2f °")
      putf("Longitude", (lon / 0.25).round / 4.0, "%05.2f °")
      puts

      tmp   = forecast.read(:tmp,   :latitude => lat, :longitude => lon).to_f
      ugrd  = forecast.read(:ugrd,  :latitude => lat, :longitude => lon).to_f
      vgrd  = forecast.read(:vgrd,  :latitude => lat, :longitude => lon).to_f
      prate = forecast.read(:prate, :latitude => lat, :longitude => lon).to_f
      rh    = forecast.read(:rh,    :latitude => lat, :longitude => lon).to_f
      tcdc  = forecast.read(:tcdc,  :latitude => lat, :longitude => lon).to_f
      pres  = forecast.read(:pres,  :latitude => lat, :longitude => lon).to_f

      temperature    = tmp - 273.15
      wind_direction = (270 - Math.atan2(ugrd, vgrd) * 180 / Math::PI) % 360
      wind_speed     = Math.sqrt(ugrd**2 + vgrd**2)
      precipitation  = prate * 3600
      humidity       = rh
      nebulosity     = tcdc
      pressure       = pres / 100.0

      wdir = compass_rose(wind_direction)

      putf("Pressure",      pressure,      "%.0f hPa")
      putf("Temperature",   temperature,   "%.0f °C")
      putf("Nebulosity",    nebulosity,    "%.0f %%")
      putf("Humidity",      humidity,      "%.0f %%")
      putf("Precipitation", precipitation, "%.1f mm")
      putf("Wind",          wind_speed,    "%.1f m/s (%s)", optional: wdir)
    end

    def putf(name, value, fmt, optional: "", color: :cyan)
      left_column = Rainbow(format("  %-20s", name)).color(color)
      right_column = Rainbow(format(fmt, value, optional))
      puts "#{left_column} #{right_column}"
    end

    def putf_debug(name, value, fmt, optional: "")
      putf(name, value, fmt, optional: optional, color: :yellow)
    end

    def compass_rose(degree)
      case degree
      when   0...45  then "N"
      when  45...90  then "NE"
      when  90...135 then "E"
      when 135...180 then "SE"
      when 180...225 then "S"
      when 225...270 then "SW"
      when 270...315 then "W"
      else                "NW"
      end
    end

    def geolocalize(location)
      Geocoder.configure(:timeout => 10)
      res = Geocoder.search(location).first
      [res.latitude, res.longitude] if res
    end
  end
end