class Forecaster::Forecast

Fetch and read a specific forecast from a GFS run.

See: www.nco.ncep.noaa.gov/pmb/products/gfs/

Public Class Methods

at(time) click to toggle source
# File lib/forecaster/forecast.rb, line 20
def self.at(time)
  # There is a forecast every 3 hours after a run for 384 hours.
  t = time.utc
  fct = Time.utc(t.year, t.month, t.day, (t.hour / 3) * 3)
  run = Time.utc(t.year, t.month, t.day, (t.hour / 6) * 6)
  run -= 6 * 3600 if run == fct

  last_run = Forecast.last_run_at
  run = last_run if run > last_run

  fct_hour = (fct - run) / 3600

  raise "Time too far in the future" if fct_hour > 384

  Forecast.new(run.year, run.month, run.day, run.hour, fct_hour)
end
last_run_at() click to toggle source
# File lib/forecaster/forecast.rb, line 10
def self.last_run_at
  # There is a new GFS run every 6 hours starting at midnight UTC, and it
  # takes approximately 3 to 5 hours before a run is available online, so
  # to be on the safe side we return the previous one.
  now = Time.now.utc
  run = Time.utc(now.year, now.month, now.day, (now.hour / 6) * 6)

  run - 6 * 3600
end
new(year, month, day, run_hour, fct_hour) click to toggle source
# File lib/forecaster/forecast.rb, line 37
def initialize(year, month, day, run_hour, fct_hour)
  @year = year
  @month = month
  @day = day
  @run_hour = run_hour
  @fct_hour = fct_hour
end

Public Instance Methods

dirname() click to toggle source
# File lib/forecaster/forecast.rb, line 53
def dirname
  subdir = format("%04d%02d%02d%02d", @year, @month, @day, @run_hour)
  File.join(Forecaster.configuration.cache_dir, subdir)
end
fetch() click to toggle source

This method will save the forecast file in the cache directory. But only the parts of the file containing the fields defined in the configuration will be downloaded.

# File lib/forecaster/forecast.rb, line 75
def fetch
  return if fetched?

  ranges = fetch_ranges

  # Select which byte ranges to download
  records = Forecaster.configuration.records.values
  filtered_ranges = records.map { |k| ranges[k] }

  fetch_grib2(filtered_ranges)
end
fetch_grib2(ranges, progress_block: nil) click to toggle source
# File lib/forecaster/forecast.rb, line 122
def fetch_grib2(ranges, progress_block: nil)
  FileUtils.mkpath(dirname)
  path = File.join(dirname, filename)

  streamer = lambda do |chunk, remaining, total|
    File.open(path, "ab") { |f| f.write(chunk) }
    progress_block.call(total - remaining, total) if progress_block
  end

  byte_ranges = ranges.map { |r| r.join("-") }.join(",")
  headers = { "Range" => "bytes=#{byte_ranges}" }

  begin
    Excon.get(url, :headers => headers, :response_block => streamer)
  rescue Excon::Errors::Error => e
    File.delete(path)
    raise "Download of '#{url}' failed: #{e}"
  end
end
fetch_ranges() click to toggle source

Fetch the index file of a GRIB2 file containing the data of a forecast.

Returns a hashmap of every records in the file with their byte ranges.

With this method we can avoid downloading unnecessary parts of the GRIB2 file by matching the records defined in the configuration. It can also be used to set up the later.

# File lib/forecaster/forecast.rb, line 94
def fetch_ranges
  begin
    res = Excon.get("#{url}.idx")
  rescue Excon::Errors::Error
    raise "Download of '#{url}.idx' failed"
  end
  lines = res.body.lines.map { |line| line.split(":") }
  lines.each_index.each_with_object({}) do |i, ranges|
    # A typical line (before the split on `:`) looks like this:
    # `12:4593854:d=2016051118:TMP:2 mb:9 hour fcst:`
    line = lines[i]
    next_line = lines[i + 1] # NOTE: Will be `nil` on the last line

    # The fourth and fifth fields constitue the key to identify the records
    # defined in `Forecaster::Configuration`.
    key = ":#{line[3]}:#{line[4]}:"

    # The second field is the first byte of the record in the GRIB2 file.
    ranges[key] = [line[1].to_i]

    # To get the last byte we need to read the next line.
    # If we are on the last line we won't be able to get the last byte,
    # but we don't need it according to the section 14.35.1 Byte Ranges
    # of RFC 2616.
    ranges[key] << next_line[1].to_i - 1 if next_line
  end
end
fetched?() click to toggle source
# File lib/forecaster/forecast.rb, line 68
def fetched?
  File.exist?(File.join(dirname, filename))
end
filename() click to toggle source
# File lib/forecaster/forecast.rb, line 58
def filename
  format("gfs.t%02dz.pgrb2.0p25.f%03d", @run_hour, @fct_hour)
end
read(field, latitude: 0.0, longitude: 0.0) click to toggle source
# File lib/forecaster/forecast.rb, line 142
def read(field, latitude: 0.0, longitude: 0.0)
  wgrib2 = Forecaster.configuration.wgrib2_path
  record = Forecaster.configuration.records[field]
  path = File.join(dirname, filename)

  raise "'#{path}' not found" unless File.exist?(path)

  coords = "#{longitude} #{latitude}"
  output = `#{wgrib2} #{path} -lon #{coords} -match "#{record}"`

  raise "Could not read '#{record}' in '#{path}'" if output.empty?

  fields = output.split("\n").first.split(":")
  params = Hash[*fields.last.split(",").map { |s| s.split("=") }.flatten]

  params["val"]
end
run_time() click to toggle source
# File lib/forecaster/forecast.rb, line 45
def run_time
  Time.utc(@year, @month, @day, @run_hour)
end
time() click to toggle source
# File lib/forecaster/forecast.rb, line 49
def time
  run_time + @fct_hour * 3600
end
url() click to toggle source
# File lib/forecaster/forecast.rb, line 62
def url
  server = Forecaster.configuration.server
  subdir = format("gfs.%04d%02d%02d%02d", @year, @month, @day, @run_hour)
  format("%s/%s/%s", server, subdir, filename)
end