3

Given two large arrays of ranges...

A = [0..23, 30..53, 60..83, 90..113]
B = [-Float::INFINITY..13, 25..33, 45..53, 65..73, 85..93]

When I do a logical conjuction...

C = A.mask(B)

Then I expect

describe "Array#mask" do
  it{expect(C = A.mask(B)).to eq([0..13, 30..33, 45..53, 65..73, 90..93])}
end

It feels like it should be...

C = A & B
=> []

but that's empty because none of the ranges are identical.

Here's a pictorial example.

Logical conjuction waveform.

I've included Infinity in the range because solutions to this problem typically involve converting the Range to an Array or Set.

My current solution This is my current solution with passing tests for speed and accuracy. I was looking for comments and/or suggested improvements. The second test uses the excellent IceCube gem to generate an array of date ranges. There's an implicit assumption in my mask method that date range occurrences within each schedule do not overlap.

require 'pry'
require 'rspec'
require 'benchmark'
require 'chronic'
require 'ice_cube'
require 'active_support'
require 'active_support/core_ext/numeric'
require 'active_support/core_ext/date/calculations'

A = [0..23, 30..53, 60..83, 90..113]
B = [-Float::INFINITY..13, 25..33, 45..53, 65..73, 85..93]

class Array
  def mask(other)
    a_down = self.map{|r| [:a, r.max]}
    a_up = self.map{|r| [:a, r.min]}

    b_down = other.map{|r| [:b, r.max]}
    b_up = other.map{|r| [:b, r.min]}

    up = a_up + b_up
    down = a_down + b_down

    a, b, start, result = false, false, nil, []
    ticks = (up + down).sort_by{|i| i[1]}
    ticks.each do |tick|
      tick[0] == :a ? a = !a : b = !b
      result << (start..tick[1]) if !start.nil?
      start = a & b ? tick[1] : nil
    end
    return result
  end
end

describe "Array#mask" do
  context "simple integer array" do
    it{expect(C = A.mask(B)).to eq([0..13, 30..33, 45..53, 65..73, 90..93])}
  end

  context "larger date ranges from IceCube schedule" do
    it "should take less than 0.1 seconds" do
      year = Time.now..(Time.now + 52.weeks)
      non_premium_schedule = IceCube::Schedule.new(Time.at(0)) do |s|
        s.duration = 12.hours
        s.add_recurrence_rule IceCube::Rule.weekly.day(:monday, :tuesday, :wednesday, :thursday, :friday).hour_of_day(7).minute_of_hour(0)
      end
      rota_schedule = IceCube::Schedule.new(Time.at(0)) do |s|
        s.duration = 7.hours
        s.add_recurrence_rule IceCube::Rule.weekly(2).day(:tuesday).hour_of_day(15).minute_of_hour(30)
      end
      np = non_premium_schedule.occurrences_between(year.min, year.max).map{|d| d..d+non_premium_schedule.duration}
      rt = rota_schedule.occurrences_between(year.min, year.max).map{|d| d..d+rota_schedule.duration}
      expect(Benchmark.realtime{np.mask(rt)}).to be < 0.1
    end
  end
end

It feels odd that you can't do this with Ruby's existing core methods? Am I missing something? I find myself calculating range intersections on a fairly regular basis.

It also occurred to me that you could use the same method to find an intersection between two single ranges by passing single item arrays. e.g.

[(54..99)].mask[(65..120)]

I realise I've kind of answered my own question but thought I would leave it here as a reference for others.

Community
  • 1
  • 1
Kevin Monk
  • 1,434
  • 1
  • 16
  • 23

1 Answers1

4

I'm not sure I really understand your question; I'm a little confused by your expect statement, and I don't know why your arrays aren't the same size. That said, if you want to calculate the intersection of two ranges, I like this monkey-patch (from Ruby: intersection between two ranges):

class Range
  def intersection(other)
    return nil if (self.max < other.begin or other.max < self.begin) 
    [self.begin, other.begin].max..[self.max, other.max].min
  end
  alias_method :&, :intersection
end

and then you can do:

A = [0..23, 30..53, 60..83, 0..0, 90..113]
B = [-Float::INFINITY..13, 25..33, 45..53, 65..73, 85..93]

A.zip(B).map { |x, y| x & y }
# => [0..13, 30..33, nil, nil, 90..93]

which seems a reasonable result...

EDIT

If you monkeypatch Range as posted above, and then do:

# your initial data
A = [0..23, 30..53, 60..83, 90..113]
B = [-Float::INFINITY..13, 25..33, 45..53, 65..73, 85..93]

A.product(B).map {|x, y| x & y }.compact
# => [0..13, 30..33, 45..53, 65..73, 90..93]

You get the results you specify. No idea how it compares performance-wise, and I'm not sure about the sort order...

Community
  • 1
  • 1
Jacob Brown
  • 7,221
  • 4
  • 30
  • 50
  • Thanks for your answer. Unfortunately it does not work when A and B are of different length OR if a range in A covers multiple ranges in B. The arrays are different sizes because my actual use case is schedules from the IceCube gem. So the ranges may be recurring over a day, month, week or year. In this particular case I'm trying to calculate the hours worked in non-premium-time (Mon-Fri 7am - 7pm) for a work rota. – Kevin Monk Jan 12 '15 at 21:36
  • P.S. Interesting to see the Array#zip method. I'd not used or investigated this before. Took me a while to get my head around what it actually did until I realised it was like the interdigitating teeth of a zip. – Kevin Monk Jan 12 '15 at 21:41
  • That's a neat solution but the arrays are typically 200 - 500 elements long so the product array could easily get to 500^2 = 250k in length. I like the concept though. – Kevin Monk Jan 12 '15 at 22:08
  • @KevinMonk It sounds like you're not really wanting the intersection of Ranges if you're concerned about comparing some ranges with potentially many other ranges. You might benefit from considering a program design where you use a custom class to represent collections of these Range values and then build on this answer to implement the less agnostic functionality that you need beyond basic Range intersection. – Iron Savior Jan 13 '15 at 00:03
  • @IronSavior That's a fair point. I agree. It's a particular 'class' of Array. ScheduleArray or ArrayRange or something like that. I trained as an electronic engineer and in the hardware world this is as simple a problem as it gets; it's a two-input AND gate. It made me wonder if there was some kind of IO based solution but I don't have a sufficient grasp of what an IO object is to write it. – Kevin Monk Jan 13 '15 at 12:26
  • @KevinMonk try not to dwell too much on it being implemented in terms of a collection of ranges. Implementation details are a means, not an end. I think this answer is on the right track, but remember that your purpose is comparing two series of values--you may find that you must use one side of the comparison for many iterations of the other side. This isn't too hard if your collections of ranges are ordered by the start time. – Iron Savior Jan 13 '15 at 13:53