'Calculate time delta ignoring a certain time range

I'm trying to calculate unavailability time of an equipment, having as input an alarm log with the start datetime and the end datetime of it.

Also, I don't have to take into account the range [1AM : 5AM] in the unavailability calculation.

So for example:

  • if the alarm starts at 2AM and ends at 10AM the same day, the unavailability will be of 5 hours (5AM : 10AM)
  • if the alarm starts at 12AM (midnight) and ends at 10AM the same day, the unavailability will be of 6 hours (12AM : 1AM + 5AM : 10AM)

My problem comes when the alarm last for a longer time (>24h) because the range [1AM : 5AM] might appear multiple times and I don't find the logic to count it appearance properly.

Here's a peak at the code I made for now :

    unavailability = timedelta(0)
    start_time_changed = False
    end_time_changed = False

    start_unavailability = start_date
    end_unavailability = end_date

    #Alarm starts between 1AM and 5AM
    if (
        start_date >= start_date.replace(hour = 1, minute = 0, second = 0) 
        and start_date <= start_date.replace(hour = 5, minute = 0, second = 0)
    ) :
        start_unavailability = start_date.replace(hour = 5, minute = 0, second = 0)
        start_time_changed = True
    #Alarm ends between 1AM and 5AM
    if (
        end_date >= end_date.replace(hour = 1, minute = 0, second = 0) 
        and end_date <= end_date.replace(hour = 5, minute = 0, second = 0)
    ) :
        end_unavailability = end_date.replace(hour = 1, minute = 0, second = 0)
        end_time_changed = True

    #Alarm remains for less than 24 hours
    if to_hours(end_date - start_date) in range(24) :
        #Alarm starts and ends the same day
        if start_date.date() == end_date.date() :
            #Alarm starts and ends between 1AM and 5AM
            if start_time_changed and end_time_changed :
                unavailability = timedelta(0)
            elif start_time_changed or end_time_changed :
                unavailability = end_unavailability - start_unavailability
            else :
                unavailability = end_unavailability - start_unavailability - timedelta(hours=4)
        #Alarm starts and ends on different days    
        else :
            if start_time_changed or end_time_changed :
                unavailability = end_unavailability - start_unavailability
            else :
                unavailability = end_unavailability - start_unavailability - timedelta(hours=4)
    #Alarm remains between 24 & 48 hours       
    elif to_hours(end_date - start_date) in range(24, 48) :
        print('alarm > 24h')
        if end_unavailability.day - start_unavailability.day == 1 :
            unavailability = start_unavailability.replace(day = start_unavailability.day + 1, hour = 1, minute = 0, second = 0) - start_unavailability
            unavailability = unavailability + end_unavailability - end_unavailability.replace(hour = 5, minute = 0, second = 0)
        if end_unavailability.day - start_unavailability.day == 2 :
            unavailability = start_unavailability.replace(day = start_unavailability.day + 1, hour = 1, minute = 0, second = 0) - start_unavailability
            unavailability = unavailability + end_unavailability - end_unavailability.replace(hour = 5, minute = 0, second = 0) + timedelta(hours = 20)
        
    elif to_hours(end_date - start_date) > 48 :
        print('alarm > 48h')
    else :
        print('Error: ' + str(to_hours(end_date - start_date)))

Is there a better way to just calculate the timedelta between start and end datetime and just count the appearance of the ignored time range?



Solution 1:[1]

I think this is a case of sitting down with a pen and paper first and having a good think about the problem without looking at code.

One thing that jumped out to me is, every complete 24 hour period has 4 hours missing. You don't know where, but you know for sure the whole period is in there. So we can multiply whole days by 20 hours, and all we have to do is deal with the period under 24 hours (which your script already does).

For the rest I've used the concept of time spans, where we check if we overlap, and if we do, we calculate the duration of the overlap and remove it from our count. I'm not 100% about this code, you'd want to write some tests for it.

This is quite nice, because it's easier to extend this to more, or different excluded windows.

from datetime import datetime, timedelta


def get_hours_unavailable(start: datetime, end: datetime) -> int:
    # First split into total days and left over hours as we know that all
    # complete 24 hour periods span a gap, and therefore are 20 hours
    days, hours = divmod(_get_duration_in_hours(start, end), 24)

    # Let's remove all the whole days from the end to deal with the left-overs.
    # We know this period is less than 24 hours
    end -= timedelta(days=days)

    # Check each possible excluded period against our start and stop. Notice 
    # the use of a set here. If the two periods are the same this will only
    # happen once
    for span_start, span_end in {
        (_time_on_day(start, 1), _time_on_day(start, 5)),
        (_time_on_day(end, 1), _time_on_day(end, 5)),
    }:
        # Remove the duration of any overlap
        hours -= _get_duration_in_hours(
            start=max(start, span_start), end=min(end, span_end)
        )

    return days * 20 + hours


def _get_duration_in_hours(start, end):
    return max((end - start).total_seconds() / 3600, 0)


def _time_on_day(date, hour):
    return date.replace(hour=hour, minute=0, second=0, microsecond=0)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1