Source code for satkit.time.timeinterval

# satkit: Satellite Mission Analysis and Design for Python
#
# Copyright (C) 2023 Egemen Imre
#
# Licensed under GNU GPL v3.0. See LICENSE.rst for more info.
"""
Time interval module.

`TimeInterval` class stores time intervals and `TimeIntervalList` class stores
lists of `TimeInterval` objects.
"""

import portion as p
from org.orekit.time import AbsoluteDate, TimeStamped
from pint import Quantity
from portion import Interval

from satkit import u
from satkit.time.time import AbsoluteDateExt

_EPS_TIME = 10 * u.ns
"""Allowable time threshold, this much 'out of bounds' is allowed when assuming two
instances of time are *practically* equal. This helps with floating point artifacts
such as round-off errors."""


[docs]class TimeInterval: """ Represent and manipulate a single time interval. This is a thin wrapper around the :class:`Interval` class from `portion` package (for the Atomic intervals), using `AbsoluteDateExt` classes under the hood. """ _interval: Interval = None def __init__( self, start_time: type[AbsoluteDate], end_time: type[AbsoluteDate], start_inclusive=True, end_inclusive=True, ): # upgrade to AbsoluteDateExt and deep copy in the process start_interval = AbsoluteDateExt(start_time) end_interval = AbsoluteDateExt(end_time) # Initialise the interval _interval = p.closed(start_interval, end_interval).replace( left=start_inclusive, right=end_inclusive ) self._interval = _interval @classmethod def _validate(cls, start_time, end_time) -> bool: if isinstance(start_time, AbsoluteDate) and isinstance(end_time, AbsoluteDate): # start and end times are AbsoluteDate or a subclass if start_time.isAfter(end_time): # end time is earlier than start time - raise error raise ValueError( f"End time ({end_time}) is earlier than start time" f"({start_time})" ) else: # One or both of start and end dates are of wrong type if not isinstance(end_time, type[AbsoluteDate]): raise ValueError( f"End time is an instance of {end_time.__class__()}, " f"only type[AbsoluteDate] (e.g., AbsoluteDateExt) classes are allowed." ) if not isinstance(start_time, type[AbsoluteDate]): raise ValueError( f"Start time is an instance of {start_time.__class__()}, " f"only type[AbsoluteDate] (e.g., AbsoluteDateExt) classes are allowed." ) # check for empty instances if start_time.isCloseTo(end_time, _EPS_TIME.m_as("sec")): return False return True def __new__( cls, start_time: type[AbsoluteDate], end_time: type[AbsoluteDate], start_inclusive=True, end_inclusive=True, ): # validate the inputs if cls._validate(start_time, end_time): return super().__new__(cls)
[docs] @u.wraps(None, (None, "s", None, None), False) @staticmethod def from_duration( start_time: type[AbsoluteDate], duration: float | Quantity, start_inclusive=True, end_inclusive=True, ) -> "TimeInterval": """ Generates a TimeInterval object from a start time and a duration. Parameters ---------- start_time : Type[AbsoluteDate] Start time duration : Quantity of float duration [seconds] start_inclusive : bool True if the start of the interval is inclusive (closed), False if exclusive (open) end_inclusive : bool True if the start of the interval is inclusive (closed), False if exclusive (open) Returns ------- TimeInterval The new TimeInterval object """ return TimeInterval( start_time, start_time.shiftedBy(float(duration)), start_inclusive=start_inclusive, end_inclusive=end_inclusive, )
[docs] @staticmethod def from_interval(interval: "TimeInterval") -> "TimeInterval": """ Generates a deep copy of the TimeInterval object. Parameters ---------- interval : TimeInterval TimeInterval to be copied Returns ------- TimeInterval The new, deep copied TimeInterval object """ return TimeInterval( interval.start, interval.end, start_inclusive=interval.is_start_inclusive, end_inclusive=interval.is_end_inclusive, )
[docs] def is_in_interval(self, time): """ Checks whether the requested time is within the time interval. Parameters ---------- time : Time Time to be checked Returns ------- bool True if time is within the reference interval, False otherwise """ # check upper and lower boundaries for tolerance is_start_equal = time.isCloseTo(self.start, _EPS_TIME) is_end_equal = time.isCloseTo(self.end, _EPS_TIME) # check start if is_start_equal: # time at starting edge - is edge closed? if self._interval.left: return True else: return False # check end if is_end_equal: # time at end edge - is edge closed? if self._interval.right: return True else: return False # Time not edges, do a regular check return self._interval.contains(time)
[docs] def is_equal(self, interval, tolerance=_EPS_TIME): """ Checks whether two intervals are (almost) equal in value. If the two start or end values are as close as `tolerance`, then they are assumed to be equal in value. The default tolerance is given in `_EPS_TIME` and is on the order of nanoseconds. Parameters ---------- interval : TimeInterval Time interval to be checked tolerance : float or Quantity the separation, in seconds, under which the two bounds of the interval will be considered close to each other Returns ------- bool True if interval start and end are (almost) equal, False otherwise """ is_start_equal = interval.start.isCloseTo(self.start, tolerance) is_end_equal = interval.end.isCloseTo(self.end, tolerance) return True if is_start_equal and is_end_equal else False
[docs] def contains(self, interval): """ Checks whether the requested interval is contained within this (self) interval. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- bool True if check interval is contained within this interval, False otherwise """ if self.is_in_interval(interval.start) and self.is_in_interval(interval.end): return True else: return False
[docs] def is_intersecting(self, interval): """ Checks whether the requested interval intersects (or is contained within) the reference interval. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- bool True if check interval intersects with the reference interval, False otherwise """ intersection = self._interval.intersection(interval._interval) if intersection.empty: # There is absolutely no intersection return False if intersection.upper.isCloseTo(intersection.lower, _EPS_TIME): # intersection below tolerance - practically empty intersection return False else: # see what the underlying function says return self._interval.overlaps(interval._interval)
[docs] def intersect(self, interval): """ Intersection operator for a time interval and this time interval. Returns a new interval that is the Intersection of two intervals, or None if there is no intersection. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- TimeInterval A new interval that is the Intersection of two intervals, or `None` if there is no intersection """ if self.is_intersecting(interval): intersection = self._interval.intersection(interval._interval) return _create_interval_from_portion(intersection) else: return None
[docs] def union(self, interval): """ Union operator for a time interval and this time interval. Returns a new interval that is the Union of two intervals, or None if there is no intersection. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- TimeInterval A new interval that is the Union of two intervals, or `None` if there is no intersection """ if self.is_intersecting(interval): union = self._interval.union(interval._interval) return _create_interval_from_portion(union) else: return None
[docs] @u.wraps(None, (None, "s", "s", None, None), False) def expand( self, start_delta=0, end_delta=0, start_inclusive=True, end_inclusive=True, ): """ Expands (or shrinks) the interval. Generates a new, expanded (or shrunk) `TimeInterval`, where: - new interval start: interval_start - start_delta - new interval end: interval_end + end_delta Negative start and/or end times are possible (to shrink the interval), though values ending in a negative interval will throw a `ValueError`. This method can be used to modify the ends of the interval (open or closed) as well. Parameters ---------- start_delta : Quantity or float The delta time to expand the start of the interval (or to shrink, with negative values) [seconds] end_delta : Quantity or float The delta time to expand the end of the interval (or to shrink, with negative values) [seconds] start_inclusive : bool True if the start of the new interval is inclusive (closed), False if exclusive (open) end_inclusive : bool True if the start of the new interval is inclusive (closed), False if exclusive (open) Returns ------- TimeInterval A new `TimeInterval` that is the result of the requested change Raises ------ ValueError Raised if the requested expansion results in a negative duration interval """ start = self.start - start_delta * u.s end = self.end + end_delta * u.s duration = end - start if duration > _EPS_TIME: return TimeInterval( start, end, start_inclusive=start_inclusive, end_inclusive=end_inclusive, ) else: raise ValueError( "Duration of the expanded/shrunk interval is negative or zero." )
@property def duration(self) -> Quantity: """ Computes the duration of the interval. Parameters ---------- Returns ------- Quantity Duration of the interval (always positive) """ return self.end - self.start @property def start(self) -> AbsoluteDateExt: """Returns the start time of the interval.""" return self._interval.lower @property def end(self) -> AbsoluteDateExt: """Returns the end time of the interval.""" return self._interval.upper @property def is_start_inclusive(self) -> bool: """Returns whether the start time of the interval is inclusive.""" return self._interval.left @property def is_end_inclusive(self) -> bool: """Returns whether the end time of the interval is inclusive.""" return self._interval.right def __str__(self): txt = "" if self._interval.left: txt += "[" else: txt += "(" txt += f" {str(self._interval.lower)}" txt += f" {str(self._interval.upper)} " if self._interval.right: txt += "]" else: txt += ")" return txt @property def p_interval(self): """ Returns the underlying `Interval` object. .. warning:: Most users will not need to access this object. Intended for developer use only. """ return self._interval
[docs]class TimeIntervalList: """ Represent and manipulate time intervals. This is a thin wrapper around the :class:`portion.interval.Interval` class, using `AbsoluteDateExt` classes under the hood. `start_valid` and `end_valid` values are used to mark the start and end of this list of time intervals. If they are not specified, the beginning and end points of the list of `TimeInterval` instances are used. If a `valid_interval` is not specified (None), the beginning and end points of the `TimeInterval` are used. Parameters ---------- intervals : list[TimeInterval] or None List of intervals valid_interval : TimeInterval Time interval within which this `TimeInterval` is valid """ def __init__(self, intervals: list[TimeInterval], valid_interval=None): self._intervals: list = [] if intervals: # if start_times is None, then there is no time interval defined # Fill the `Interval` list and merge as necessary p_intervals = self._to_p_intervals(intervals) # Fill the atomic `TimeInterval` objects using the merged list self._intervals = self._to_time_intervals(p_intervals) # Init range of validity self._valid_interval = self.__init_validity_rng(valid_interval) def __init_validity_rng(self, valid_interval=None): """ Initialises the beginning and end of the range of validity. If a `valid_interval` is not specified, the beginning and end points of the `TimeInterval` are used. Parameters ---------- valid_interval : TimeInterval Time interval within which this `TimeInterval` is valid Returns ------- TimeInterval A new `TimeInterval` instance containing the start and end of validity """ if valid_interval: # interval is not None return TimeInterval.from_interval(valid_interval) else: # Interval is None # No init time for validity defined, use the first interval init start_valid_tmp = self.get_interval(0).start start_inclusive = self.get_interval(0).p_interval.left # No end time for validity defined, use the last interval end end_valid_tmp = self.get_interval(-1).end end_inclusive = self.get_interval(0).p_interval.right # Generate the `TimeInterval` return TimeInterval( start_valid_tmp, end_valid_tmp, start_inclusive=start_inclusive, end_inclusive=end_inclusive, )
[docs] def is_in_interval(self, time: "TimeStamped"): """ Checks whether the requested time is within the time interval list. Parameters ---------- time : TimeStamped Time to be checked Returns ------- bool `True` if time is within the interval list, `False` otherwise. Also returns `False` if requested time is outside validity interval. """ # Is time within validity interval? if not self.valid_interval.is_in_interval(time): return False # Are there any events that contain this time instant? for interval in self._intervals: if interval.is_in_interval(time): return True # If we are here, then no interval contains the time return False
[docs] def is_intersecting(self, interval): """ Checks whether the requested interval intersects (or is contained within) the interval list. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- bool `True` if check interval intersects with the interval list, `False` otherwise """ if len(self._intervals) == 0: # No interval present in the list, hence no intersection return False # While not very elegant, loop through the interval list to check # for intersections intersect_intervals = [ interval_member for interval_member in self.intervals if interval_member.is_intersecting(interval) ] return True if len(intersect_intervals) > 0 else False
[docs] def intersect(self, interval): """ Intersection operator for a time interval and this time interval list. Returns a new interval that is the Intersection of the interval and the time interval list, or None if there is no intersection. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- TimeInterval A new interval that is the Intersection of the interval and the time interval list, or None if there is no intersection. """ if len(self.intervals) == 0: # No interval present in the list, hence no intersection possible return None # While not very elegant, loop through the interval list to check # for intersections intersect_intervals = [ interval_member.intersect(interval) for interval_member in self.intervals if interval_member.is_intersecting(interval) ] if len(intersect_intervals) > 0: # There can be only a single intersection return intersect_intervals[0] else: # no intersection return None
[docs] def intersect_list(self, interval_list): """ Intersection operator for a time interval list and this time interval list. Returns a new interval list that is the Intersection of `interval_list` and this time interval list, or None if there is no intersection even in the validity intervals. Parameters ---------- interval_list : TimeIntervalList Time interval list to be checked Returns ------- TimeIntervalList A new interval list that is the Intersection of `interval_list` and this time interval list, or None if there is no intersection even in the validity intervals. """ if not self.valid_interval.is_intersecting(interval_list.valid_interval): # Validity intervals do not intersect, hence no intersection possible return None common_valid_interval = self.valid_interval.intersect( interval_list.valid_interval ) if not interval_list.intervals or not self.intervals: # There are no intervals to intersect in either self or the target # intervals, hence no intersection possible return TimeIntervalList(None, valid_interval=common_valid_interval) # Compute the portion intervals p_self_intervals = TimeIntervalList._to_p_intervals(self.intervals) p_other_intervals = TimeIntervalList._to_p_intervals(interval_list.intervals) # Do the Intersection p_final = p_self_intervals.intersection(p_other_intervals) return TimeIntervalList( self._to_time_intervals(p_final), valid_interval=common_valid_interval )
[docs] def union(self, interval): """ Union operator for a time interval and this time interval list. Returns a new interval that is the Union of the interval and the time interval list, or None if there is no intersection. Parameters ---------- interval : TimeInterval Time interval to be checked Returns ------- TimeInterval A new interval that is the Union of the interval and the time interval list, or None if there is no intersection. """ if len(self.intervals) == 0: # No interval present in the list, hence no intersection possible return None # While not very elegant, loop through the interval list to check # for intersections union_intervals = [ interval_member.union(interval) for interval_member in self.intervals if interval_member.is_intersecting(interval) ] if len(union_intervals) > 0: # There can be only a single intersection return union_intervals[0] else: # no intersection return None
[docs] def union_list(self, interval_list): """ Union operator for a time interval list and this time interval list. Returns a new interval list that is the Union of `interval_list` and this time interval list, or None if there is no intersection even in the validity intervals. Parameters ---------- interval_list : TimeIntervalList Time interval list to be checked Returns ------- TimeIntervalList A new interval list that is the Union of `interval_list` and this time interval list, or None if there is no intersection. """ if not self.valid_interval.is_intersecting(interval_list.valid_interval): # Validity intervals do not intersect, hence no intersection possible return None common_valid_interval = self.valid_interval.intersect( interval_list.valid_interval ) # Compute the portion intervals p_self_intervals = TimeIntervalList._to_p_intervals(self.intervals) p_other_intervals = TimeIntervalList._to_p_intervals(interval_list.intervals) # Do the Union p_union = p_self_intervals.union(p_other_intervals) # Reduce union to the common interval p_common = common_valid_interval.p_interval p_final = p_union.intersection(p_common) return TimeIntervalList( self._to_time_intervals(p_final), valid_interval=common_valid_interval )
[docs] def invert(self): """ Creates an *inverted* (or complement) copy of this time interval list, while keeping the same validity range. For example, for a single interval of `[t0, t1]` in a validity interval `[T0,T1]`, the inverted interval list would be `[T0,t0]` and `[t1,T1]`. If there are no intervals, the inverse becomes the entire validity interval. Parameters ---------- Returns ------- TimeIntervalList A new `TimeIntervalList` that has the same validity range but the individual intervals are inverted. """ # Convert `TimeInterval` list to `Interval` p_interval = self._to_p_intervals(self._intervals) # Do the inversion p_int_inverted = ~p_interval # Fix the ends as necessary with validity interval p_validity = self._to_p_intervals(self.valid_interval) p_int_inverted = p_int_inverted.intersection(p_validity) # Generate the `TimeInterval` list intervals = self._to_time_intervals(p_int_inverted) # Create the `TimeIntervalList` object return TimeIntervalList(intervals, valid_interval=self.valid_interval)
[docs] def get_interval(self, index): """ Gets the time interval for the given index. Parameters ---------- index : int requested index Returns ------- TimeInterval `TimeInterval` corresponding to the index Raises ------ IndexError Requested index is out of bounds """ return self._intervals[index]
@property def valid_interval(self) -> TimeInterval: """ Gets the time interval of validity for the `TimeIntervalList`. """ return self._valid_interval @property def intervals(self): """ Gets the time intervals within this `TimeIntervalList`. """ return self._intervals @staticmethod def _to_time_intervals(p_intervals): """ Converts a `pint` `Interval` list to a `TimeInterval` list. Parameters ---------- p_intervals : Interval List of intervals (pint Interval objects) Returns ------- list[TimeInterval] `TimeInterval` object with the list of time intervals """ intervals: list = [] # Fill the atomic `TimeInterval` objects using the merged list for p_interval in p_intervals: # check for empty instances if not p_interval.lower.isCloseTo(p_interval.upper, _EPS_TIME): # duration not empty, add the interval intervals.append(_create_interval_from_portion(p_interval)) return intervals @staticmethod def _to_p_intervals(intervals): """ Converts a `TimeInterval` instance or a list to an `Interval` list (portion library objects). This is usually done to merge and simplify the elements of the list. Parameters ---------- intervals : TimeInterval or list[TimeInterval] List of intervals Returns ------- `Interval` object with the list of time intervals """ # Fill the `Interval` list and merge as necessary p_intervals = p.empty() if isinstance(intervals, list): for interval in intervals: # make sure interval is not None if interval: p_intervals = p_intervals.union(interval.p_interval) else: # intervals object is a single TimeInterval p_intervals = intervals.p_interval return p_intervals def __str__(self): txt = "" if self._intervals: # List not empty for interval in self._intervals: txt += str(interval) + "\n" else: txt = "Time interval list is empty." return txt
def _create_interval_from_portion(interval): """ Create a new `TimeInterval` from a given :class:`Interval` instance. Parameters ---------- interval : Interval input `Interval` instance Returns ------- TimeInterval New `TimeInterval` instance """ return TimeInterval( interval.lower, interval.upper, start_inclusive=interval.left, end_inclusive=interval.right, )