#!/usr/bin/env python
# cardinal_pythonlib/interval.py
"""
===============================================================================
Original code copyright (C) 2009-2022 Rudolf Cardinal (rudolf@pobox.com).
This file is part of cardinal_pythonlib.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===============================================================================
**Time interval classes and related functions.**
"""
# =============================================================================
# Imports
# =============================================================================
import datetime
import logging
from typing import List, Optional, Set, Tuple, Union
log = logging.getLogger(__name__)
# =============================================================================
# Constants
# =============================================================================
SECONDS_PER_MINUTE = 60
MINUTES_PER_HOUR = 60
HOURS_PER_DAY = 24
DAYS_PER_WEEK = 7
DAYS_PER_YEAR = 365 # approx...
SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY
SECONDS_PER_WEEK = SECONDS_PER_DAY * DAYS_PER_WEEK
SECONDS_PER_YEAR = SECONDS_PER_DAY * DAYS_PER_YEAR # approx...
NORMAL_DAY_START_H = 7
NORMAL_DAY_END_H = 19
BANK_HOLIDAYS = [
datetime.datetime.strptime(x, "%Y-%m-%d").date()
for x in [
# https://www.gov.uk/bank-holidays
# All bank holiday dates vary, even the date-based ones; e.g. if
# Christmas Day is a Sunday, then the Christmas Day substitute bank
# holiday is Tue 27 Dec, after the Boxing Day Monday bank holiday.
# 2014
"2014-01-01", # New Year's Day
"2014-04-18", # Good Friday
"2014-04-21", # Easter Monday
"2014-05-05", # Early May Bank Holiday
"2014-05-26", # Spring Bank Holiday
"2014-08-25", # Summer Bank Holiday
"2014-12-25", # Christmas Day
"2014-12-26", # Boxing Day
# 2015
"2015-01-01", # New Year's Day
"2015-04-03", # Good Friday
"2015-04-06", # Easter Monday
"2015-05-04", # Early May Bank Holiday
"2015-05-25", # Spring Bank Holiday
"2015-08-31", # Summer Bank Holiday
"2015-12-25", # Christmas Day
"2015-12-28", # Boxing Day (substitute)
# 2016
"2016-01-01", # New Year's Day
"2016-03-25", # Good Friday
"2016-03-28", # Easter Monday
"2016-05-02", # Early May Bank Holiday
"2016-05-30", # Spring Bank Holiday
"2016-08-29", # Summer Bank Holiday
"2016-12-26", # Boxing Day
"2016-12-27", # Christmas Day (substitute)
# 2017
"2017-01-02", # New Year's Day (substitute day)
"2017-04-14", # Good Friday
"2017-04-17", # Easter Monday
"2017-05-01", # Early May bank holiday
"2017-05-29", # Spring bank holiday
"2017-08-28", # Summer bank holiday
"2017-12-25", # Christmas Day
"2017-12-26", # Boxing Day
# 2018
"2018-01-01", # New Year's Day
"2018-03-30", # Good Friday
"2018-04-02", # Easter Monday
"2018-05-07", # Early May bank holiday
"2018-05-28", # Spring bank holiday
"2018-08-28", # Summer bank holiday
"2018-12-25", # Christmas Day
"2018-12-26", # Boxing Day
# 2019
"2019-01-01", # New Year's Day
"2019-04-19", # Good Friday
"2019-04-22", # Easter Monday
"2019-05-06", # Early May bank holiday
"2019-05-27", # Spring bank holiday
"2019-08-26", # Summer bank holiday
"2019-12-25", # Christmas Day
"2019-12-26", # Boxing Day
# 2020
"2020-01-01", # New Year's Day
"2020-04-10", # Good Friday
"2020-04-13", # Easter Monday
"2020-05-08", # Early May bank holiday (VE Day)
"2020-05-25", # Spring bank holiday
"2020-08-31", # Summer bank holiday
"2020-12-25", # Christmas Day
"2020-12-28", # Boxing Day (substitute day)
# 2021
"2021-01-01", # New Year's Day
"2021-04-02", # Good Friday
"2021-04-05", # Easter Monday
"2021-05-03", # Early May bank holiday
"2021-05-31", # Spring bank holiday
"2021-08-30", # Summer bank holiday
"2021-12-27", # Christmas Day (substitute day)
"2021-12-28", # Boxing Day (substitute day)
# 2022
"2022-01-03", # New Year's Day
"2022-04-15", # Good Friday
"2022-04-18", # Easter Monday
"2022-05-02", # Early May bank holiday
"2022-06-02", # Spring bank holiday
"2022-06-03", # Platinum Jubilee bank holiday
"2022-08-29", # Summer bank holiday
"2022-12-26", # Boxing Day
"2022-12-27", # Christmas Day (substitute day)
# 2023
"2023-01-02", # New Year's Day (substitute day)
"2023-04-07", # Good Friday
"2023-04-10", # Easter Monday
"2023-05-01", # Early May bank holiday
"2023-05-29", # Spring bank holiday
"2023-08-28", # Summer bank holiday
"2023-12-25", # Christmas Day
"2023-12-26", # Boxing Day
# Don't forget to add more in years to come.
]
]
FIRST_KNOWN_BANK_HOLIDAY = min(x for x in BANK_HOLIDAYS)
LAST_KNOWN_BANK_HOLIDAY = max(x for x in BANK_HOLIDAYS)
# =============================================================================
# Helper functions
# =============================================================================
[docs]def convert_duration(
duration: datetime.timedelta, units: str
) -> Optional[float]:
"""
Convert a ``datetime.timedelta`` object -- a duration -- into other
units. Possible units:
``s``, ``sec``, ``seconds``
``m``, ``min``, ``minutes``
``h``, ``hr``, ``hours``
``d``, ``days``
``w``, ``weeks``
``y``, ``years``
"""
if duration is None:
return None
s = duration.total_seconds()
if units in ["s", "sec", "seconds"]:
return s
if units in ["m", "min", "minutes"]:
return s / SECONDS_PER_MINUTE
if units in ["h", "hr", "hours"]:
return s / SECONDS_PER_HOUR
if units in ["d", "days"]:
return s / SECONDS_PER_DAY
if units in ["w", "weeks"]:
return s / SECONDS_PER_WEEK
if units in ["y", "years"]:
return s / SECONDS_PER_YEAR
raise ValueError(f"Unknown units: {units}")
[docs]def is_uk_bank_holiday(date: datetime.date) -> bool:
"""
Is the specified date (a ``datetime.date`` object) a UK bank holiday?
Uses the ``BANK_HOLIDAYS`` list.
"""
if date < FIRST_KNOWN_BANK_HOLIDAY:
log.warning(
f"Date {date} is earlier than first known bank holiday of "
f"{FIRST_KNOWN_BANK_HOLIDAY}; cardinal_pythonlib.interval "
f"may need updating"
)
elif date > LAST_KNOWN_BANK_HOLIDAY:
log.warning(
f"Date {date} is later than last known bank holiday of "
f"{LAST_KNOWN_BANK_HOLIDAY}; cardinal_pythonlib.interval "
f"may need updating"
)
return date in BANK_HOLIDAYS
[docs]def is_weekend(date: datetime.date) -> bool:
"""
Is the specified date (a ``datetime.date`` object) a weekend?
"""
return date.weekday() in [5, 6]
[docs]def is_saturday(date: datetime.date) -> bool:
"""
Is the specified date (a ``datetime.date`` object) a Saturday?
"""
return date.weekday() == 5
[docs]def is_sunday(date: datetime.date) -> bool:
"""
Is the specified date (a ``datetime.date`` object) a Sunday?
"""
return date.weekday() == 6
[docs]def is_normal_uk_working_day(date: datetime.date) -> bool:
"""
Is the specified date (a ``datetime.date`` object) a normal working day,
i.e. not a weekend or a bank holiday?
"""
return not (is_weekend(date) or is_uk_bank_holiday(date))
# =============================================================================
# Interval
# =============================================================================
[docs]class Interval(object):
"""
Object representing a time interval, with start and end objects that are
normally ``datetime.datetime`` objects (though with care, a subset of some
methods are possible with ``datetime.date`` objects; caveat emptor, and
some methods will crash).
Does not handle open-ended intervals (−∞, +∞) or null intervals.
There's probably an existing class for this...
"""
def __init__(
self, start: datetime.datetime, end: datetime.datetime
) -> None:
"""
Creates the interval.
"""
if start is None or end is None:
raise TypeError("Invalid interval creation")
if start > end:
(start, end) = (end, start)
self.start = start
self.end = end
def __repr__(self) -> str:
"""
Returns the canonical string representation of the object.
"""
return f"Interval(start={self.start!r}, end={self.end!r})"
def __str__(self) -> str:
"""
Returns a string representation of the object.
"""
return f"{formatdt(self.start)} − {formatdt(self.end)}"
def __add__(self, value: datetime.timedelta) -> "Interval":
"""
Adds a constant (``datetime.timedelta`` object) to the interval's start
and end. Returns the new :class:`Interval`.
"""
return Interval(self.start + value, self.end + value)
def __lt__(self, other: "Interval") -> bool:
"""
Allows sorting (on start time).
"""
return self.start < other.start
[docs] def copy(self) -> "Interval":
"""
Returns a copy of the interval.
"""
return Interval(self.start, self.end)
[docs] def overlaps(self, other: "Interval") -> bool:
"""
Does this interval overlap the other?
Overlap:
.. code-block:: none
S--------S S---S S---S
O---O O---O O---O
Simpler method of testing is for non-overlap!
.. code-block:: none
S---S S---S
O---O O---O
"""
return not (self.end <= other.start or self.start >= other.end)
[docs] def contiguous(self, other: "Interval") -> bool:
"""
Does this interval overlap or touch the other?
"""
return not (self.end < other.start or self.start > other.end)
[docs] def contains(
self, time: datetime.datetime, inclusive: bool = True
) -> bool:
"""
Does the interval contain a momentary time?
Args:
time: the ``datetime.datetime`` to check
inclusive: use inclusive rather than exclusive range checks?
"""
if inclusive:
return self.start <= time <= self.end
else:
return self.start < time < self.end
[docs] def within(self, other: "Interval", inclusive: bool = True) -> bool:
"""
Is this interval contained within the other?
Args:
other: the :class:`Interval` to check
inclusive: use inclusive rather than exclusive range checks?
"""
if not other:
return False
if inclusive:
return self.start >= other.start and self.end <= other.end
else:
return self.start > other.start and self.end < other.end
[docs] def union(self, other: "Interval") -> "Interval":
"""
Returns an interval spanning the extent of this and the ``other``.
"""
return Interval(min(self.start, other.start), max(self.end, other.end))
[docs] def intersection(self, other: "Interval") -> Optional["Interval"]:
"""
Returns an :class:`Interval` representing the intersection of this and
the ``other``, or ``None`` if they don't overlap.
"""
if not self.contiguous(other):
return None
return Interval(max(self.start, other.start), min(self.end, other.end))
[docs] def cut(
self, times: Union[datetime.datetime, List[datetime.datetime]]
) -> List["Interval"]:
"""
Returns a list of intervals produced by using times (a list of
``datetime.datetime`` objects, or a single such object) as a set of
knives to slice this interval.
"""
if not isinstance(times, list):
# Single time
time = times
if not self.contains(time):
return []
return [Interval(self.start, time), Interval(time, self.end)]
else:
# Multiple times
times = [t for t in times if self.contains(t)] # discard others
times.sort()
times = [self.start] + times + [self.end]
intervals = []
for i in range(len(times) - 1):
intervals.append(Interval(times[i], times[i + 1]))
return intervals
[docs] def duration(self) -> datetime.timedelta:
"""
Returns a datetime.timedelta object representing the duration of this
interval.
"""
return self.end - self.start
[docs] def duration_in(self, units: str) -> float:
"""
Returns the duration of this interval in the specified units, as
per :func:`convert_duration`.
"""
return convert_duration(self.duration(), units)
[docs] @staticmethod
def wholeday(date: datetime.date) -> "Interval":
"""
Returns an :class:`Interval` covering the date given (midnight at the
start of that day to midnight at the start of the next day).
"""
start = datetime.datetime.combine(date, datetime.time())
return Interval(start, start + datetime.timedelta(days=1))
[docs] @staticmethod
def daytime(
date: datetime.date,
daybreak: datetime.time = datetime.time(NORMAL_DAY_START_H),
nightfall: datetime.time = datetime.time(NORMAL_DAY_END_H),
) -> "Interval":
"""
Returns an :class:`Interval` representing daytime on the date given.
"""
return Interval(
datetime.datetime.combine(date, daybreak),
datetime.datetime.combine(date, nightfall),
)
[docs] @staticmethod
def dayspan(
startdate: datetime.date,
enddate: datetime.date,
include_end: bool = True,
) -> Optional["Interval"]:
"""
Returns an :class:`Interval` representing the date range given, from
midnight at the start of the first day to midnight at the end of the
last (i.e. at the start of the next day after the last), or if
include_end is False, 24h before that.
If the parameters are invalid, returns ``None``.
"""
if enddate < startdate:
return None
if enddate == startdate and include_end:
return None
start_dt = datetime.datetime.combine(startdate, datetime.time())
end_dt = datetime.datetime.combine(enddate, datetime.time())
if include_end:
end_dt += datetime.timedelta(days=1)
return Interval(start_dt, end_dt)
[docs] def component_on_date(self, date: datetime.date) -> Optional["Interval"]:
"""
Returns the part of this interval that falls on the date given, or
``None`` if the interval doesn't have any part during that date.
"""
return self.intersection(Interval.wholeday(date))
[docs] def day_night_duration(
self,
daybreak: datetime.time = datetime.time(NORMAL_DAY_START_H),
nightfall: datetime.time = datetime.time(NORMAL_DAY_END_H),
) -> Tuple[datetime.timedelta, datetime.timedelta]:
"""
Returns a ``(day, night)`` tuple of ``datetime.timedelta`` objects
giving the duration of this interval that falls into day and night
respectively.
"""
daytotal = datetime.timedelta()
nighttotal = datetime.timedelta()
startdate = self.start.date()
enddate = self.end.date()
ndays = (enddate - startdate).days + 1
for i in range(ndays):
date = startdate + datetime.timedelta(days=i)
component = self.component_on_date(date)
# ... an interval on a single day
day = Interval.daytime(date, daybreak, nightfall)
daypart = component.intersection(day)
if daypart is not None:
daytotal += daypart.duration()
nighttotal += component.duration() - daypart.duration()
else:
nighttotal += component.duration()
return daytotal, nighttotal
[docs] def duration_outside_uk_normal_working_hours(
self,
starttime: datetime.time = datetime.time(NORMAL_DAY_START_H),
endtime: datetime.time = datetime.time(NORMAL_DAY_END_H),
weekdays_only: bool = False,
weekends_only: bool = False,
) -> datetime.timedelta:
"""
Returns a duration (a ``datetime.timedelta`` object) representing the
number of hours outside normal working hours.
This is not simply a subset of :meth:`day_night_duration`, because
weekends are treated differently (they are always out of hours).
The options allow the calculation of components on weekdays or weekends
only.
"""
if weekdays_only and weekends_only:
raise ValueError("Can't have weekdays_only and weekends_only")
ooh = datetime.timedelta() # ooh = out of (normal) hours
startdate = self.start.date()
enddate = self.end.date()
ndays = (enddate - startdate).days + 1
for i in range(ndays):
date = startdate + datetime.timedelta(days=i)
component = self.component_on_date(date)
# ... an interval on a single day
if not is_normal_uk_working_day(date):
if weekdays_only:
continue
ooh += component.duration() # all is out-of-normal-hours
else:
if weekends_only:
continue
normalday = Interval.daytime(date, starttime, endtime)
normalpart = component.intersection(normalday)
if normalpart is not None:
ooh += component.duration() - normalpart.duration()
else:
ooh += component.duration()
return ooh
[docs] def n_weekends(self) -> int:
"""
Returns the number of weekends that this interval covers. Includes
partial weekends.
"""
startdate = self.start.date()
enddate = self.end.date()
ndays = (enddate - startdate).days + 1
in_weekend = False
n_weekends = 0
for i in range(ndays):
date = startdate + datetime.timedelta(days=i)
if not in_weekend and is_weekend(date):
in_weekend = True
n_weekends += 1
elif in_weekend and not is_weekend(date):
in_weekend = False
return n_weekends
[docs] def saturdays_of_weekends(self) -> Set[datetime.date]:
"""
Returns the dates of all Saturdays that are part of weekends that this
interval covers (each Saturday representing a unique identifier for
that weekend). The Saturday itself isn't necessarily the part of the
weekend that the interval covers!
"""
startdate = self.start.date()
enddate = self.end.date()
ndays = (enddate - startdate).days + 1
saturdays = set()
for i in range(ndays):
date = startdate + datetime.timedelta(days=i)
if is_saturday(date):
saturdays.add(date)
elif is_sunday(date):
saturdays.add(date - datetime.timedelta(days=1))
return saturdays
# =============================================================================
# IntervalList
# =============================================================================
[docs]class IntervalList(object):
"""
Object representing a list of Intervals.
Maintains an internally sorted state (by interval start time).
"""
_ONLY_FOR_NO_INTERVAL = (
"Only implemented for IntervalList objects with no_overlap == True"
)
# -------------------------------------------------------------------------
# Constructor, representations, copying
# -------------------------------------------------------------------------
def __init__(
self,
intervals: List[Interval] = None,
no_overlap: bool = True,
no_contiguous: bool = True,
) -> None:
"""
Creates the :class:`IntervalList`.
Args:
intervals: optional list of :class:`Interval` objects to
incorporate into the :class:`IntervalList`
no_overlap: merge intervals that overlap (now and on subsequent
addition)?
no_contiguous: if ``no_overlap`` is set, merge intervals that are
contiguous too?
"""
# DO NOT USE intervals=[] in the function signature; that's the route
# to a mutable default and a huge amount of confusion as separate
# objects appear non-independent.
self.intervals = [] if intervals is None else list(intervals)
self.no_overlap = no_overlap
self.no_contiguous = no_contiguous
for i in self.intervals:
if not isinstance(i, Interval):
raise TypeError(
f"IntervalList creation failed: contents are not all "
f"Interval: {self.intervals!r}"
)
self._tidy()
def __repr__(self) -> str:
"""
Returns the canonical string representation of the object.
"""
return (
f"IntervalList(intervals={self.intervals!r}, "
f"no_overlap={self.no_overlap}, "
f"no_contiguous={self.no_contiguous})"
)
[docs] def copy(
self, no_overlap: bool = None, no_contiguous: bool = None
) -> "IntervalList":
"""
Makes and returns a copy of the :class:`IntervalList`. The
``no_overlap``/``no_contiguous`` parameters can be changed.
Args:
no_overlap: merge intervals that overlap (now and on subsequent
addition)?
no_contiguous: if ``no_overlap`` is set, merge intervals that are
contiguous too?
"""
if no_overlap is None:
no_overlap = self.no_overlap
if no_contiguous is None:
no_contiguous = self.no_contiguous
return IntervalList(
self.intervals, no_overlap=no_overlap, no_contiguous=no_contiguous
)
[docs] def list(self) -> List[Interval]:
"""
Returns the contained list of :class:`Interval` objects.
"""
return self.intervals
# -------------------------------------------------------------------------
# Add an interval
# -------------------------------------------------------------------------
[docs] def add(self, interval: Interval) -> None:
"""
Adds an interval to the list. If ``self.no_overlap`` is True, as is the
default, it will merge any overlapping intervals thus created.
"""
if interval is None:
return
if not isinstance(interval, Interval):
raise TypeError("Attempt to insert non-Interval into IntervalList")
self.intervals.append(interval)
self._tidy()
# -------------------------------------------------------------------------
# Internal consolidation functions, and sorting
# -------------------------------------------------------------------------
def _tidy(self) -> None:
"""
Removes overlaps, etc., and sorts.
"""
if self.no_overlap:
self.remove_overlap(self.no_contiguous) # will sort
else:
self._sort()
def _sort(self) -> None:
"""
Sorts (in place) by interval start time.
"""
self.intervals.sort()
def _remove_overlap_sub(self, also_remove_contiguous: bool) -> bool:
"""
Called by :meth:`remove_overlap`. Removes the first overlap found.
Args:
also_remove_contiguous: treat contiguous (as well as overlapping)
intervals as worthy of merging?
Returns:
bool: ``True`` if an overlap was removed; ``False`` otherwise
"""
# Returns
for i in range(len(self.intervals)):
for j in range(i + 1, len(self.intervals)):
first = self.intervals[i]
second = self.intervals[j]
if also_remove_contiguous:
test = first.contiguous(second)
else:
test = first.overlaps(second)
if test:
newint = first.union(second)
self.intervals.pop(j)
self.intervals.pop(i) # note that i must be less than j
self.intervals.append(newint)
return True
return False
[docs] def remove_overlap(self, also_remove_contiguous: bool = False) -> None:
"""
Merges any overlapping intervals.
Args:
also_remove_contiguous: treat contiguous (as well as overlapping)
intervals as worthy of merging?
"""
overlap = True
while overlap:
overlap = self._remove_overlap_sub(also_remove_contiguous)
self._sort()
def _any_overlap_or_contiguous(self, test_overlap: bool) -> bool:
"""
Do any of the intervals overlap?
Args:
test_overlap: if ``True``, test for overlapping intervals; if
``False``, test for contiguous intervals.
"""
for i in range(len(self.intervals)):
for j in range(i + 1, len(self.intervals)):
first = self.intervals[i]
second = self.intervals[j]
if test_overlap:
test = first.overlaps(second)
else:
test = first.contiguous(second)
if test:
return True
return False
# -------------------------------------------------------------------------
# Simple descriptions
# -------------------------------------------------------------------------
[docs] def is_empty(self) -> bool:
"""
Do we have no intervals?
"""
return len(self.intervals) == 0
[docs] def any_overlap(self) -> bool:
"""
Do any of the intervals overlap?
"""
return self._any_overlap_or_contiguous(test_overlap=True)
[docs] def any_contiguous(self) -> bool:
"""
Are any of the intervals contiguous?
"""
return self._any_overlap_or_contiguous(test_overlap=False)
# -------------------------------------------------------------------------
# Start, end, range, duration
# -------------------------------------------------------------------------
[docs] def start_datetime(self) -> Optional[datetime.datetime]:
"""
Returns the start date of the set of intervals, or ``None`` if empty.
"""
if not self.intervals:
return None
return self.intervals[0].start
# Internally sorted by start date, so this is always OK.
[docs] def end_datetime(self) -> Optional[datetime.datetime]:
"""
Returns the end date of the set of intervals, or ``None`` if empty.
"""
if not self.intervals:
return None
return max([x.end for x in self.intervals])
[docs] def start_date(self) -> Optional[datetime.date]:
"""
Returns the start date of the set of intervals, or ``None`` if empty.
"""
if not self.intervals:
return None
return self.start_datetime().date()
[docs] def end_date(self) -> Optional[datetime.date]:
"""
Returns the end date of the set of intervals, or ``None`` if empty.
"""
if not self.intervals:
return None
return self.end_datetime().date()
[docs] def extent(self) -> Optional[Interval]:
"""
Returns an :class:`Interval` running from the earliest start of an
interval in this list to the latest end. Returns ``None`` if we are
empty.
"""
if not self.intervals:
return None
return Interval(self.start_datetime(), self.end_datetime())
[docs] def total_duration(self) -> datetime.timedelta:
"""
Returns a ``datetime.timedelta`` object with the total sum of
durations. If there is overlap, time will be double-counted, so beware!
"""
total = datetime.timedelta()
for interval in self.intervals:
total += interval.duration()
return total
# -------------------------------------------------------------------------
# Intervals and durations within our list
# -------------------------------------------------------------------------
[docs] def get_overlaps(self) -> "IntervalList":
"""
Returns an :class:`IntervalList` containing intervals representing
periods of overlap between intervals in this one.
"""
overlaps = IntervalList()
for i in range(len(self.intervals)):
for j in range(i + 1, len(self.intervals)):
first = self.intervals[i]
second = self.intervals[j]
ol = first.intersection(second)
if ol is not None:
overlaps.add(ol)
return overlaps
[docs] def durations(self) -> List[datetime.timedelta]:
"""
Returns a list of ``datetime.timedelta`` objects representing the
durations of each interval in our list.
"""
return [x.duration() for x in self.intervals]
[docs] def longest_duration(self) -> Optional[datetime.timedelta]:
"""
Returns the duration of the longest interval, or None if none.
"""
if not self.intervals:
return None
return max(self.durations())
[docs] def longest_interval(self) -> Optional[Interval]:
"""
Returns the longest interval, or ``None`` if none.
"""
longest_duration = self.longest_duration()
for i in self.intervals:
if i.duration() == longest_duration:
return i
return None
[docs] def shortest_duration(self) -> Optional[datetime.timedelta]:
"""
Returns the duration of the longest interval, or ``None`` if none.
"""
if not self.intervals:
return None
return min(self.durations())
[docs] def shortest_interval(self) -> Optional[Interval]:
"""
Returns the shortest interval, or ``None`` if none.
"""
shortest_duration = self.shortest_duration()
for i in self.intervals:
if i.duration() == shortest_duration:
return i
return None
[docs] def first_interval_starting(
self, start: datetime.datetime
) -> Optional[Interval]:
"""
Returns our first interval that starts with the ``start`` parameter, or
``None``.
"""
for i in self.intervals:
if i.start == start:
return i
return None
[docs] def first_interval_ending(
self, end: datetime.datetime
) -> Optional[Interval]:
"""
Returns our first interval that ends with the ``end`` parameter, or
``None``.
"""
for i in self.intervals:
if i.end == end:
return i
return None
# -------------------------------------------------------------------------
# Gaps and subsets
# -------------------------------------------------------------------------
[docs] def gaps(self) -> "IntervalList":
"""
Returns all the gaps between intervals, as an :class:`IntervalList`.
"""
if len(self.intervals) < 2:
return IntervalList(None)
gaps = []
for i in range(len(self.intervals) - 1):
gap = Interval(self.intervals[i].end, self.intervals[i + 1].start)
gaps.append(gap)
return IntervalList(gaps)
[docs] def shortest_gap(self) -> Optional[Interval]:
"""
Find the shortest gap between intervals, or ``None`` if none.
"""
gaps = self.gaps()
return gaps.shortest_interval()
[docs] def shortest_gap_duration(self) -> Optional[datetime.timedelta]:
"""
Find the duration of the shortest gap between intervals, or ``None`` if
none.
"""
gaps = self.gaps()
return gaps.shortest_duration()
[docs] def subset(
self, interval: Interval, flexibility: int = 2
) -> "IntervalList":
"""
Returns an IntervalList that's a subset of this one, only containing
intervals that meet the "interval" parameter criterion. What "meet"
means is defined by the ``flexibility`` parameter.
``flexibility == 0``: permits only wholly contained intervals:
.. code-block:: none
interval:
I----------------I
intervals in self that will/won't be returned:
N---N N---N Y---Y N---N N---N
N---N N---N
``flexibility == 1``: permits overlapping intervals as well:
.. code-block:: none
I----------------I
N---N Y---Y Y---Y Y---Y N---N
N---N N---N
``flexibility == 2``: permits adjoining intervals as well:
.. code-block:: none
I----------------I
N---N Y---Y Y---Y Y---Y N---N
Y---Y Y---Y
"""
if flexibility not in [0, 1, 2]:
raise ValueError("subset: bad flexibility value")
permitted = []
for i in self.intervals:
if flexibility == 0:
ok = i.start > interval.start and i.end < interval.end
elif flexibility == 1:
ok = i.end > interval.start and i.start < interval.end
else:
ok = i.end >= interval.start and i.start <= interval.end
if ok:
permitted.append(i)
return IntervalList(permitted)
[docs] def gap_subset(
self, interval: Interval, flexibility: int = 2
) -> "IntervalList":
"""
Returns an IntervalList that's a subset of this one, only containing
*gaps* between intervals that meet the interval criterion.
See :meth:`subset` for the meaning of parameters.
"""
return self.gaps().subset(interval, flexibility)
# -------------------------------------------------------------------------
# Descriptions relating to the working week (for rota work)
# -------------------------------------------------------------------------
[docs] def n_weekends(self) -> int:
"""
Returns the number of weekends that the intervals collectively touch
(where "touching a weekend" means "including time on a Saturday or a
Sunday").
"""
saturdays = set()
for interval in self.intervals:
saturdays.update(interval.saturdays_of_weekends())
return len(saturdays)
[docs] def duration_outside_nwh(
self,
starttime: datetime.time = datetime.time(NORMAL_DAY_START_H),
endtime: datetime.time = datetime.time(NORMAL_DAY_END_H),
) -> datetime.timedelta:
"""
Returns the total duration outside normal working hours, i.e.
evenings/nights, weekends (and Bank Holidays).
"""
total = datetime.timedelta()
for interval in self.intervals:
total += interval.duration_outside_uk_normal_working_hours(
starttime, endtime
)
return total
[docs] def max_consecutive_days(self) -> Optional[Tuple[int, Interval]]:
"""
The length of the longest sequence of days in which all days include
an interval.
Returns:
tuple:
``(longest_length, longest_interval)`` where
``longest_interval`` is a :class:`Interval` containing the
start and end date of the longest span -- or ``None`` if we
contain no intervals.
"""
if len(self.intervals) == 0:
return None
startdate = self.start_date()
enddate = self.end_date()
seq = ""
ndays = (enddate - startdate).days + 1
for i in range(ndays):
date = startdate + datetime.timedelta(days=i)
wholeday = Interval.wholeday(date)
if any([x.overlaps(wholeday) for x in self.intervals]):
seq += "+"
else:
seq += " "
# noinspection PyTypeChecker
longest = max(seq.split(), key=len)
longest_len = len(longest)
longest_idx = seq.index(longest)
longest_interval = Interval.dayspan(
startdate + datetime.timedelta(days=longest_idx),
startdate + datetime.timedelta(days=longest_idx + longest_len),
)
return longest_len, longest_interval
def _sufficient_gaps(
self,
startdate: datetime.date,
enddate: datetime.date,
requiredgaps: List[datetime.timedelta],
flexibility: int,
) -> Tuple[bool, Optional[Interval]]:
"""
Are there sufficient gaps (specified by ``requiredgaps``) in the date
range specified? This is a worker function for :meth:`sufficient_gaps`.
"""
requiredgaps = list(requiredgaps) # make a copy
interval = Interval.dayspan(startdate, enddate, include_end=True)
# log.debug(">>> _sufficient_gaps")
gaps = self.gap_subset(interval, flexibility)
gapdurations = gaps.durations()
gaplist = gaps.list()
gapdurations.sort(reverse=True) # longest gap first
requiredgaps.sort(reverse=True) # longest gap first
# log.debug("... gaps = {}".format(gaps))
# log.debug("... gapdurations = {}".format(gapdurations))
# log.debug("... requiredgaps = {}".format(requiredgaps))
while requiredgaps:
# log.debug("... processing gap")
if not gapdurations:
# log.debug("<<< no gaps left")
return False, None
if gapdurations[0] < requiredgaps[0]:
# log.debug("<<< longest gap is too short")
return False, self.first_interval_ending(gaplist[0].start)
gapdurations.pop(0)
requiredgaps.pop(0)
gaplist.pop(0)
# ... keeps gaplist and gapdurations mapped to each other
# log.debug("<<< success")
return True, None
[docs] def sufficient_gaps(
self,
every_n_days: int,
requiredgaps: List[datetime.timedelta],
flexibility: int = 2,
) -> Tuple[bool, Optional[Interval]]:
"""
Are gaps present sufficiently often?
For example:
.. code-block:: python
every_n_days=21
requiredgaps=[
datetime.timedelta(hours=62),
datetime.timedelta(hours=48),
]
... means "is there at least one 62-hour gap and one (separate) 48-hour
gap in every possible 21-day sequence within the IntervalList?
- If ``flexibility == 0``: gaps must be WHOLLY WITHIN the interval.
- If ``flexibility == 1``: gaps may OVERLAP the edges of the interval.
- If ``flexibility == 2``: gaps may ABUT the edges of the interval.
Returns ``(True, None)`` or ``(False, first_failure_interval)``.
"""
if len(self.intervals) < 2:
return False, None
startdate = self.start_date()
enddate = self.end_date()
ndays = (enddate - startdate).days + 1
if ndays <= every_n_days:
# Our interval is too short, or just right
return self._sufficient_gaps(
startdate, enddate, requiredgaps, flexibility
)
for i in range(ndays - every_n_days):
j = i + every_n_days
a = startdate + datetime.timedelta(days=i)
b = startdate + datetime.timedelta(days=j)
sufficient, ffi = self._sufficient_gaps(
a, b, requiredgaps, flexibility
)
if not sufficient:
return False, ffi
return True, None
# -------------------------------------------------------------------------
# Cumulative time calculations
# -------------------------------------------------------------------------
[docs] def cumulative_time_to(
self, when: datetime.datetime
) -> datetime.timedelta:
"""
Returns the cumulative time contained in our intervals up to the
specified time point.
"""
assert self.no_overlap, self._ONLY_FOR_NO_INTERVAL
cumulative = datetime.timedelta()
for interval in self.intervals:
if interval.start >= when:
break
elif interval.end <= when:
# complete interval precedes "when"
cumulative += interval.duration()
else: # start < when < end
cumulative += when - interval.start
return cumulative
[docs] def cumulative_gaps_to(
self, when: datetime.datetime
) -> datetime.timedelta:
"""
Return the cumulative time within our gaps, up to ``when``.
"""
gaps = self.gaps()
return gaps.cumulative_time_to(when)
[docs] def time_afterwards_preceding(
self, when: datetime.datetime
) -> Optional[datetime.timedelta]:
"""
Returns the time after our last interval, but before ``when``.
If ``self`` is an empty list, returns ``None``.
"""
if self.is_empty():
return None
end_time = self.end_datetime()
if when <= end_time:
return datetime.timedelta()
else:
return when - end_time
[docs] def cumulative_before_during_after(
self, start: datetime.datetime, when: datetime.datetime
) -> Tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]:
"""
For a given time, ``when``, returns the cumulative time
- after ``start`` but before ``self`` begins, prior to ``when``;
- after ``start`` and during intervals represented by ``self``, prior
to ``when``;
- after ``start`` and after at least one interval represented by
``self`` has finished, and not within any intervals represented by
``self``, and prior to ``when``.
Args:
start: the start time of interest (e.g. before ``self`` begins)
when: the time of interest
Returns:
tuple: ``before, during, after``
Illustration
.. code-block:: none
start: S
self: X---X X---X X---X X---X
when: W
before: ----
during: ----- ----- -----
after: ------- ------- ----
"""
assert (
self.no_overlap
), "Only implemented for IntervalList objects with no_overlap == True"
no_time = datetime.timedelta()
earliest_interval_start = self.start_datetime()
# Easy special cases
if when <= start:
return no_time, no_time, no_time
if self.is_empty() or when <= earliest_interval_start:
return when - start, no_time, no_time
# Now we can guarantee:
# - "self" is a non-empty list
# - start < when
# - earliest_interval_start < when
# Before
if earliest_interval_start < start:
before = no_time
else:
before = earliest_interval_start - start
# During
during = self.cumulative_time_to(when)
after = self.cumulative_gaps_to(when) + self.time_afterwards_preceding(
when
)
return before, during, after