Wako
Wako

Reputation: 158

Get empty months in hash

I have a hash of Month->Value

{"Apr 2016"=>6, "Aug 2016"=>9, "Jan 2017"=>11, "Apr 2017"=>6, "May 2017"=>9, "Jun 2017"=>1, "Jul 2017"=>9}

I would know what is the best way to get all empty months between the first and the last value

I would like something like

{"Apr 2016"=>6, May 2016=>0, Jun 2016=>0 .... "Aug 2016"=>9, "Sep 2016" => 0 "Jan 2017"=>11, "Apr 2017"=>6, "May 2017"=>9, "Jun 2017"=>1, "Jul 2017"=>9}

Upvotes: 1

Views: 559

Answers (4)

Cary Swoveland
Cary Swoveland

Reputation: 110685

Code

require 'date'

def fill_in_missing_months(dates)
  date_fmt = '%b %Y'
  fm, lm = dates.keys.
                 minmax_by { |date| Date.strptime(date, date_fmt) }.
                 map       { |date| Date.strptime(date, date_fmt) }
  (0..12*(lm.year-fm.year) + lm.month-fm.month).each_with_object({}) do |_,h|
    str = fm.strftime(date_fmt)
    h[str] = dates.fetch(str, 0)
    fm >>= 1
  end
end

Example

dates = {"Apr 2016"=>6, "Aug 2016"=>9, "Jan 2017"=>11, "Apr 2017"=>6,
         "May 2017"=>9, "Jun 2017"=>1, "Jul 2017"=>9}

fill_in_missing_months(dates)
  #=> {"Apr 2016"=>6, "May 2016"=>0, "Jun 2016"=>0, "Jul 2016"=>0, "Aug 2016"=>9,
  #    "Sep 2016"=>0, "Oct 2016"=>0, "Nov 2016"=>0, "Dec 2016"=>0, "Jan 2017"=>11,
  #    "Feb 2017"=>0, "Mar 2017"=>0, "Apr 2017"=>6, "May 2017"=>9, "Jun 2017"=>1,
  # "Jul 2017"=>9}

Explanation

Experienced Rubiests: A gory-detail-follows advisory has been issued, so you may wish to skip the rest of my answer.

Ruby expands fm >>= 1 to fm = fm >> 1 when parsing the code. Date#>> advances the date by the number of months given by its argument, which here is 1.

In addition to :>>, see the docs for the methods Integer#times, Hash#fetch, Date::strptime, Date#strftime, Date#year, Date#month, Enumerator#with_object and Enumerable#minimax_by (not including the more common methods such as Enumerable#map). Recall # in Date#year denotes an instance method whereas :: in [Date::strptime] indicates a class method.

For dates given in the example, the steps are as follows.

  date_fmt = '%b %Y'
  b = dates.keys
    #=> ["Apr 2016", "Aug 2016", "Jan 2017", "Apr 2017", "May 2017",
    #    "Jun 2017", "Jul 2017"]
  c = b.minmax_by { |date| Date.strptime(date, date_fmt) }
    #=> ["Apr 2016", "Jul 2017"]
  fm, lm = c.map  { |date|  Date.strptime(date, date_fmt) }
    #=> [#<Date: 2016-04-01 ((2457480j,0s,0n),+0s,2299161j)>,
    #    #<Date: 2017-07-01 ((2457936j,0s,0n),+0s,2299161j)>]
  fm #=> #<Date: 2016-04-01 ((2457480j,0s,0n),+0s,2299161j)>
  lm #=> #<Date: 2017-07-01 ((2457936j,0s,0n),+0s,2299161j)>]
  d = 0..12*(lm.year-fm.year) + lm.month-fm.month
    #=> 0..15
  e = d.each_with_object({})
    #=> #<Enumerator: 0..15:each_with_object({})>

We can see the values that will be generated by e and passed to the block by converting it to an array, using Enumerable#entries (or Enumerable#to_a).

  e.entries
    #=> [[0, {}], [1, {}],..., [15, {}]]

The hashes in these tuples is initially empty, but will be filled in as block calculations are performed.

The first element is generated by e, which is passed to the block and the block variables are set equal to its value, using a process called disambiguation or decomposition to stab out the part associated with each block variable.

_,h = e.next
   #=> [0, {}]
h  #=> {}

I've used _ as the first block variable to signify that it (the index) is not used in the block calculation. Continuing,

str = fm.strftime(date_fmt)
   #=> "Apr 2016"
h[str] = dates.fetch(str, 0)
   #=> 6
h  #=> {"Apr 2016"=>6}

In this case dates has a key "Apr 2016", so h["Apr 2016"] is set equal to dates["Apr 2016"]. In other cases dates will not have key equal to str ("May 2016", for example), so the value will be set equal to fetch's default value of 0.

fm >>= 1
  #=> #<Date: 2016-05-01 ((2457510j,0s,0n),+0s,2299161j)>

fm is now May, 2016. The remaining calculations are similar.

Upvotes: 3

Gerry
Gerry

Reputation: 10507

Here's another way, using each_with_object:

def add_months(dates)
  min, max = dates.keys.map { |date| Date.parse(date) }.minmax
  range    = (min..max).map { |date| date.strftime("%b %Y") }.uniq

  range.each_with_object({}) { |date, result| result[date] = dates[date] || 0 }
end

Output:

dates = {"Apr 2016"=>6, "Aug 2016"=>9, "Jan 2017"=>11, "Apr 2017"=>6, "May 2017"=>9, "Jun 2017"=>1, "Jul 2017"=>9}

add_months(dates)
#=> {
#     "Apr 2016"=>6,
#     "May 2016"=>0,
#     "Jun 2016"=>0,
#     "Jul 2016"=>0,
#     "Aug 2016"=>9,
#     "Sep 2016"=>0,
#     "Oct 2016"=>0,
#     "Nov 2016"=>0,
#     "Dec 2016"=>0,
#     "Jan 2017"=>11,
#     "Feb 2017"=>0,
#     "Mar 2017"=>0,
#     "Apr 2017"=>6,
#     "May 2017"=>9,
#     "Jun 2017"=>1,
#     "Jul 2017"=>9
#   }

Upvotes: 4

anka
anka

Reputation: 3857

I would suggest a method like the following:

require 'date'

def add_missing_months(dates)
    # get all months
    months = dates.keys.map{|m| Date.parse(m)}

    # get min and max
    min = months.min
    max = months.max

    # collect all missing months
    missing_months = {}
    while min < max
        min = min.next_month

        missing_months[min.strftime('%b %Y')] = 0 unless months.include?(min) 
    end

    # merge hashes
    dates.merge(missing_months)
end

Upvotes: 0

bitsapien
bitsapien

Reputation: 1833

You can use the following method to do the above :

def normalize_months(month_values)
   ordered_months = month_values.keys.map do |m| Date.parse(m) end.sort.map do |s| s.strftime('%b %Y') end
   normalized = []
   current = ordered_months.first
   while current != ordered_months.last do
     normalized << current
     current = Date.parse(current).next_month.strftime('%b %Y')
   end
   result = {}
   normalized.each do |g| result[g] = month_values[g].nil? ? 0 : month_values[g] end
   result
end

Do a require 'date' above this if you are doing this using pure ruby.

Upvotes: 1

Related Questions