Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to check for overlap of cyclic ranges (overlapping yearly recurring periods)

I am trying to find an elegant algorithm to check if two yearly recurring periods overlap. The period is year-agnostic, but a year can be expected to always be a leap year.

For example, period A = (March 1st to May 1th,) and period B = (April 1st to September 1st) overlap. Also, period A = (October 1st to February 1st) and period B = (January 1st to March 1st) overlap.

However, I found this to be more difficult than I had anticipated. The complexity comes from the periods that cross the end of the year.

I have got a working solution (see doesOverlap(A,B) method below), but I find it convulted.

# for the rest of the MWE context code, see further
# WORKING, but a bit convulted
def doesOverlap(A, B):  
    '''returns True if yearly period A and B have overlapping dates'''
    # list to track if day in year is part of a period A
    # (this could probably be done a bit cheaper with a dictionary of tuples, but not relevant for my question)
    yeardayCovered = [False for x in range(366)]  # leap year

    # mark the days of A
    for d in range(A.start, A.start + A.length):
        yeardayCovered[d % 366] = True

    # now check each of the days in B with A
    for d in range(B.start, B.start + B.length):
        if yeardayCovered[d % 366]:
            return True
    return False

I believe it should be possible to do it with less checks and more elegantly. I've tried setting one of the start days as zero offset, applying some modulo operators, and then the regular (non-cyclic) range overlapping check (Algorithm to detect overlapping periods). But I haven't got that to work for all my test cases.

#NOT WORKING CORRECTLY!!
def doesOverlap(A, B):   
    '''determines if two yearly periods have overlapping dates'''
    Astart = A.start
    Astop = A.stop
    Bstart = B.start
    Bstop = B.stop

    # start day counting at Astart, at 0
    offset = Astart
    Astart = 0
    Astop = (Astop - offset) % 366
    Bstart = (Bstart - offset) % 366
    Bstop = (Bstop - offset) % 366

    # overlap?
    # https://stackoverflow.com/a/13513973
    return (Astart <= Bstop and Bstart <= Astop)

Note: I've done the code in Python, but ideally the solution should be not too Python specific (i.e. not using functions that are typically only out-of-the-box available in Python, but not in C or C#)

# MWE (Minimal Working Example)

import datetime
import unittest


class TimePeriod:
    def __init__(self, startDay, startMonth, stopDay, stopMonth):
        self.startDay = startDay
        self.startMonth = startMonth
        self.stopDay = stopDay
        self.stopMonth = stopMonth

    def __repr__(self):
        return "From " + str(self.startDay) + "/" + str(self.startMonth) + " to " + \
            str(self.stopDay) + "/" + str(self.stopMonth)

    def _dayOfYear(self, d, m, y=2012):
        '''2012 = leap year'''
        date1 = datetime.date(year=y, day=d, month=m)
        return date1.timetuple().tm_yday

    @property
    def start(self):
        '''day of year of start of period, zero-based for easier modulo operations! '''
        return self._dayOfYear(self.startDay, self.startMonth) - 1

    @property
    def stop(self):
        '''day of year of stop of period, zero-based for easier modulo operations! '''
        return self._dayOfYear(self.stopDay, self.stopMonth) - 1

    @property
    def length(self):
        '''number of days in the time period'''
        _length = (self.stop - self.start) % 366 + 1
        return _length

def doesOverlap(A, B):
    # code from above goes here


class TestPeriods(unittest.TestCase):
    pass


def test_generator(a, b, c):
    def test(self):
        self.assertEqual(doesOverlap(a, b), c)
    return test

if __name__ == '__main__':

    #some unit tests, probably not complete coverage of all edge cases though
    tests = [["max", TimePeriod(1, 1, 31, 12), TimePeriod(1, 1, 1, 1), True],
             ["BinA", TimePeriod(1, 3, 1, 11), TimePeriod(1, 5, 1, 10), True],
             ["BoverEndA", TimePeriod(1, 1, 1, 2), TimePeriod(10, 1, 3, 3), True],
             ["BafterA", TimePeriod(1, 1, 1, 2), TimePeriod(2, 2, 3, 3), False],
             ["sBoutA", TimePeriod(1, 12, 2, 5), TimePeriod(1, 6, 1, 7), False],
             ["sBoverBeginA", TimePeriod(1, 11, 2, 5), TimePeriod(1, 10, 1, 12), True],
             ["sBinA", TimePeriod(1, 11, 2, 5), TimePeriod(1, 1, 1, 2), True],
             ["sBinA2", TimePeriod(1, 11, 2, 5), TimePeriod(1, 12, 10, 12), True],
             ["sBinA3", TimePeriod(1, 11, 2, 5), TimePeriod(1, 12, 1, 2), True],
             ["sBoverBeginA", TimePeriod(1, 11, 2, 5), TimePeriod(1, 10, 1, 12), True],
             ["Leap", TimePeriod(29, 2, 1, 4), TimePeriod(1, 10, 1, 12), False],
             ["BtouchEndA", TimePeriod(1, 2, 1, 2), TimePeriod(1, 2, 1, 3), True]]

    for i, t in enumerate(tests):
        test_name = 'test_%s' % t[0]
        test = test_generator(t[1], t[2], t[3])
        setattr(TestPeriods, test_name, test)

    # unittest.main()
    suite = unittest.TestLoader().loadTestsFromTestCase(TestPeriods)
    unittest.TextTestRunner(verbosity=2).run(suite)
like image 526
Rabarberski Avatar asked Feb 08 '23 02:02

Rabarberski


1 Answers

def overlap(a0, a1, b0, b1):
    # First we "lift" the intervals from the yearly "circle"
    # to the time "line" by adjusting the ending date if
    # ends up before starting date...
    if a1 < a0: a1 += 365
    if b1 < b0: b1 += 365

    # There is an intersection either if the two intervals intersect ...
    if a1 > b0 and a0 < b1: return True

    # ... or if they do after moving A forward or backward one year
    if a1+365 > b0 and a0+365 < b1: return True
    if a1-365 > b0 and a0-365 < b1: return True

    # otherwise there's no intersection
    return False
like image 68
6502 Avatar answered Apr 05 '23 22:04

6502