Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Split up datetime interval according to labeled partition of a week

I have shift which is a datetime interval (a pair of datetimes). My weeks have a labeled partition (every week is the same: divided into parts, and each part has a label). I want to split up shift into labeled parts (i.e. into several subintervals), according to the partition of the week.

Example. Suppose shift is the interval 2019-10-21 18:30 - 2019-10-22 08:00, and the partition of the week is as follows: Monday to Friday 07:00 - 19:00 has label A, and the rest of the week has label B. In this case the splitting of shift should be the following list of labeled subintervals:

  • 2019-10-21 18:30 - 2019-10-21 19:00 with label A,
  • 2019-10-21 19:00 - 2019-10-22 07:00 with label B, and
  • 2019-10-22 07:00 - 2019-10-22 08:00 with label A.

How do I do this in general?

Input: a datetime interval (pair), and a labeled partition of the week (how to best represent this?)

Output: a list of labeled datetime intervals (pairs).

Note that shift can start in one week and end in another week (e.g. Sunday evening to Monday morning); each week does have the same labeled partition.

like image 602
Ricardo Buring Avatar asked Oct 27 '19 11:10

Ricardo Buring


1 Answers

Here's a way to obtain the desired intervals:

from collections import namedtuple
from datetime import datetime, timedelta
import itertools as it


# Built-in as `it.pairwise` in Python 3.10+
def pairwise(iterable):
    it = iter(iterable)
    a = next(it, None)
    for b in it:
        yield (a, b)
        a = b


def beginning_of_week(d: datetime) -> datetime:
    ''' Returns the datetime object for the beginning of the week the provided day is in. '''
    return (d - timedelta(days=d.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)


Partition = namedtuple('Partition', ('start', 'stop', 'label')) # output format


def _partition_shift_within_week(start: int, stop: int, partitions):
    ''' Splits the shift (defined by `start` and `stop`) into partitions within one week. '''
    # Get partitions as ranges of absolute offsets from the beginning of the week in seconds
    labels = (x for _, x in partitions)
    absolute_offsets = it.accumulate(int(x.total_seconds()) for x, _ in partitions)
    ranges = [range(x, y) for x, y in pairwise((0, *absolute_offsets))]
    first_part_idx = [start in x for x in ranges].index(True)
    last_part_idx = [stop in x for x in ranges].index(True)
    for r, label in zip((ranges[i] for i in range(first_part_idx, last_part_idx + 1)), labels):
        yield Partition(
            timedelta(seconds=max(r.start, start)), # start of subinterval
            timedelta(seconds=min(r.stop, stop)),   # end of the subinterval
            label
        )


def _partition_shift_unjoined(shift, partitions):
    ''' Partitions a shift across weeks with partitions unjoined at the week edges. '''
    start_monday = beginning_of_week(shift[0])
    stop_monday = beginning_of_week(shift[1])
    seconds_offsets = (
        int((shift[0] - start_monday).total_seconds()),
        *[604800] * ((stop_monday - start_monday).days // 7),
        int((shift[1] - stop_monday).total_seconds()),
    )
    for x, y in pairwise(seconds_offsets):
        num_weeks, x = divmod(x, 604800)
        for part in _partition_shift_within_week(x, y - (y == 604800), partitions):
            weeks_offset = timedelta(weeks=num_weeks)
            yield Partition(
                start_monday + weeks_offset + part.start,
                start_monday + weeks_offset + part.stop,
                part.label
            )


def partition_shift(shift, partitions):
    ''' Partitions a shift across weeks. '''
    results = []
    for part in _partition_shift_unjoined(shift, partitions):
        if len(results) and results[-1].label == part.label:
            results[-1] = Partition(results[-1].start, part.stop, part.label)
        else:
            results.append(part)
    return results

Usage example:

shift = (datetime(2019, 10, 21, 18, 30), datetime(2019, 10, 22, 8, 0))

# Partitions are stored as successive offsets from the beginning of the week
partitions = (
    (timedelta(hours=7), 'B'), # Monday morning (midnight to 07:00)
    (timedelta(hours=12), 'A'),
    (timedelta(hours=12), 'B'), # Monday night & Tuesday morning (til 07:00)
    (timedelta(hours=12), 'A'),
    (timedelta(hours=12), 'B'), # Tuesday night & Wednesday morning (til 07:00)
    (timedelta(hours=12), 'A'),
    (timedelta(hours=12), 'B'), # Wednesday night & Thursday morning (til 07:00)
    (timedelta(hours=12), 'A'),
    (timedelta(hours=12), 'B'), # Thursday night & Friday morning (til 07:00)
    (timedelta(hours=12), 'A'),
    (timedelta(hours=53), 'B'), # Friday night & the weekend
)

for start, end, label in partition_shift(shift, partitions):
    print(f"'{start}' - '{end}', label: {label}")

Output:

'2019-10-21 18:30:00' - '2019-10-21 19:00:00', label: A
'2019-10-21 19:00:00' - '2019-10-22 07:00:00', label: B
'2019-10-22 07:00:00' - '2019-10-22 08:00:00', label: A

This approach assumes that the partitions are input as successive offsets from the beginning of that week. The question did not specify how the partitions would be provided, so I choose to use this format. It's nice because it guarantees they do not overlap, and uses time deltas instead of being fixed to some particular date.

Converting other ways of specifying partitions into this one, or adapting this answer to work with other ways of specifying partitions has been left as an exercise to the reader.


Here's another usage example, using the same partitions as before, but a shift that starts in the previous week, thereby demonstrating that this approach works even when the shift spans multiple weeks.

shift = (datetime(2019, 10, 19, 18, 30), datetime(2019, 10, 22, 8, 0))

for start, end, label in partition_shift(shift, partitions):
    print(f"'{start}' - '{end}', label: {label}")

Output:

'2019-10-19 18:30:00' - '2019-10-21 07:00:00', label: B
'2019-10-21 07:00:00' - '2019-10-21 19:00:00', label: A
'2019-10-21 19:00:00' - '2019-10-22 07:00:00', label: B
'2019-10-22 07:00:00' - '2019-10-22 08:00:00', label: A
like image 92
Will Da Silva Avatar answered Oct 20 '22 00:10

Will Da Silva