Source code for conditions_db.iov

#!/usr/bin/env python3

##########################################################################
# basf2 (Belle II Analysis Software Framework)                           #
# Author: The Belle II Collaboration                                     #
#                                                                        #
# See git log for contributors and copyright holders.                    #
# This file is licensed under LGPL-3.0, see LICENSE.md.                  #
##########################################################################

"""
conditions_db.iov
-----------------

This module contains classes to work with validity intervals. There's a class
for a single interval, `IntervalOfValidity` and a class to manage a set of
validities, `IoVSet`, which can be used to manipulate iov ranges
"""

import math
from itertools import product


[docs]class IntervalOfValidity: """ Interval of validity class to support set operations like union and intersection. An interval of validity is a set of runs for which something is valid. An IntervalOfValidity consists of a `first` valid run and a `final` valid run. Warning: The `final` run is inclusive so the the validity is including the final run. Each run is identified by a experiment number and a run number. Accessing `first` or `final` will return a tuple ``(experiment, run)`` but the elements can also be accessed separately with `first_exp`, `first_exp`, `final_exp` and `final_run`. For `final` there's a special case where either the run or both, the run and the experiment number are infinite. This means the validity extends to all values. If only the run number is infinite then it's valid for all further runs in this experiment. If both are infinite the validity extends to everything. For simplicity ``-1`` can be passed in instead of infinity when creating objects. """ def __init__(self, *iov): """Create a new object. It can be either instantiated by providing four values or one tuple/list with four values for first_exp, first_run, final_exp, final_run """ if len(iov) == 1 and isinstance(iov[0], (list, tuple)): iov = iov[0] if len(iov) != 4: raise ValueError("A iov should have four values") #: tuple with the first valid exp, run self.__first = tuple(iov[:2]) #: tuple with the final valid exp, run self.__final = tuple(math.inf if x == -1 else x for x in iov[2:]) if math.isinf(self.__final[0]) and not math.isinf(self.__final[1]): raise ValueError(f"Unlimited final experiment but not unlimited run: {self}") if self.__first[0] > self.__final[0]: raise ValueError(f"First exp larger than final exp: {self}") if self.__first[0] == self.__final[0] and self.__first[1] > self.__final[1]: raise ValueError(f"First run larger than final run: {self}") if self.__first[0] < 0 or self.__first[1] < 0: raise ValueError(f"Negative first exp or run: {self}")
[docs] @staticmethod def always(): """Return an iov that is valid everywhere >>> IntervalOfValidity.always() (0, 0, inf, inf) """ return IntervalOfValidity(0, 0, -1, -1)
@property def first(self): """Return the first valid experiment,run""" return self.__first @property def first_exp(self): """Return the first valid experiment""" return self.__first[0] @property def first_run(self): """Return the first valid run""" return self.__first[1] @property def final(self): """Return the final valid experiment,run""" return self.__final @property def final_exp(self): """Return the final valid experiment""" return self.__final[0] @property def final_run(self): """Return the final valid run""" return self.__final[1] def __repr__(self): """Return a printable representation""" return str(self.__first + self.__final) def __eq__(self, other): """Check for equality""" if not isinstance(other, IntervalOfValidity): return NotImplemented return (self.__first, self.__final) == (other.__first, other.__final) def __lt__(self, other): """Sort by run values""" if not isinstance(other, IntervalOfValidity): return NotImplemented return (self.__first, self.__final) < (other.__first, other.__final) def __and__(self, other): """Intersection between iovs. Will return None if the payloads don't overlap""" if not isinstance(other, IntervalOfValidity): return NotImplemented return self.intersect(other) def __or__(self, other): """Union between iovs. Will return None if the iovs don't overlap or connect to each other""" if not isinstance(other, IntervalOfValidity): return NotImplemented return self.union(other, False) def __sub__(self, other): """Difference between iovs. Will return None if nothing is left over""" if not isinstance(other, IntervalOfValidity): return NotImplemented return self.subtract(other) def __hash__(self): """Make object hashable""" return hash((self.__first, self.__final))
[docs] def subtract(self, other): """Return a new iov with the validity of the other removed. Will return None if everything is removed. Warning: If the other iov is in the middle of the validity we will return a tuple of two new iovs >>> iov1 = IntervalOfValidity(0,0,10,-1) >>> iov2 = IntervalOfValidity(5,0,5,-1) >>> iov1 - iov2 ((0, 0, 4, inf), (6, 0, 10, inf)) """ if other.first <= self.first and other.final >= self.final: # full overlap return None if other.first > self.first and other.final < self.final: # the one we want to remove is in the middle, return a pair of iovs # by subtracting two extended once iov1 = self.subtract(IntervalOfValidity(other.first + (-1, -1))) iov2 = self.subtract(IntervalOfValidity((0, 0) + other.final)) return (iov1, iov2) if other.first <= self.final and other.final >= self.first: # one sided overlap, figure out which side and calculate the remainder if self.first < other.first: end_run = other.first_run - 1 end_exp = other.first_exp if end_run >= 0 else other.first_exp - 1 return IntervalOfValidity(self.first + (end_exp, end_run)) else: start_run = other.final_run + 1 start_exp = other.final_exp if math.isinf(other.final_run): start_exp += 1 start_run = 0 return IntervalOfValidity((start_exp, start_run) + self.final) # no overlap so return unchanged return self
[docs] def intersect(self, other): """Intersection with another iov. Will return None if the payloads don't overlap >>> iov1 = IntervalOfValidity(1,0,2,5) >>> iov2 = IntervalOfValidity(2,0,2,-1) >>> iov3 = IntervalOfValidity(2,10,5,-1) >>> iov1.intersect(iov2) (2, 0, 2, 5) >>> iov2.intersect(iov3) (2, 10, 2, inf) >>> iov3.intersect(iov1) is None True """ if other.first <= self.final and other.final >= self.first: return IntervalOfValidity(*(max(self.first, other.first) + min(self.final, other.final))) return None
[docs] def union(self, other, allow_startone=False): """ Return the union with another iov. >>> iov1 = IntervalOfValidity(1,0,1,-1) >>> iov2 = IntervalOfValidity(2,0,2,-1) >>> iov3 = IntervalOfValidity(2,10,5,-1) >>> iov1.union(iov2) (1, 0, 2, inf) >>> iov2.union(iov3) (2, 0, 5, inf) >>> iov3.union(iov1) is None True Warning: This method will return None if the iovs don't overlap or connect to each other as no union can be formed. Parameters: other (IntervalOfValidity): IoV to calculate the union with allow_startone (bool): If True we will consider run 0 and run 1 the first run in an experiment. This means that if one of the iovs has un unlimited final run it can be joined with the other iov if the experiment number increases and the iov starts at run 0 and 1. If this is False just run 0 is considered the next run. >>> iov1 = IntervalOfValidity(0,0,0,-1) >>> iov2 = IntervalOfValidity(1,1,1,-1) >>> iov1.union(iov2, False) is None True >>> iov1.union(iov2, True) (0, 0, 1, inf) """ # check the trivial case of overlapping if other.first <= self.final and other.final >= self.first: return IntervalOfValidity(min(self.first, other.first) + max(self.final, other.final)) # ok, let's do the less simple case where they don't overlap but join directly for i1, i2 in (self, other), (other, self): if (i1.first == (i2.final_exp, i2.final_run + 1) or (math.isinf(i2.final_run) and (i1.first_exp == i2.final_exp + 1) and (i1.first_run == 0 or allow_startone and i1.first_run == 1))): return IntervalOfValidity(i2.first + i1.final) # no union possible: not directly connected and not overlapping return None
[docs] def contains(self, exp, run): """Check if a run is part of the validtiy""" return self.first <= (exp, run) <= self.final
@property def is_open(self): """Check whether the iov is valid until infinity""" #: Doxygen complains without this string. return self.final == (math.inf, math.inf) @property def tuple(self): """Return the iov as a tuple with experiment/run numbers replaced with -1 This is mostly helpful where infinity is not supported and is how the intervals are represented in the database. >>> a = IntervalOfValidity.always() >>> a (0, 0, inf, inf) >>> a.tuple (0, 0, -1, -1) """ return self.__first + tuple(-1 if math.isinf(x) else x for x in self.__final)
[docs]class IoVSet: """A set of iovs. This class allows to combine iovs into a set. New iovs can be added with `add()` and will be combined with existing iovs if possible. The final, minimal number of iovs can be obtained with the `iovs` property >>> a = IoVSet() >>> a.add((0,0,0,2)) >>> a.add((0,3,0,5)) >>> a.add((0,8,0,9)) >>> a {(0, 0, 0, 5), (0, 8, 0, 9)} """ def __init__(self, iterable=None, *, allow_overlaps=False, allow_startone=False): """Create a new set. >>> a = IoVSet([IntervalOfValidity(3,6,3,-1), (0,0,3,5)]) >>> a {(0, 0, 3, inf)} Parameters: iterable: if not None it should be an iterable of IntervalOfValidity objects or anything that can be converted to an IntervalOfValidity. allow_overlaps (bool): If False adding which overlaps with any existing iov in the set will raise a ValueError. allow_startone (bool): If True also join iovs if one covers the whole experiment and the next one starts at run 1 in the next experiment. If False they will only be joined if the next one starts at run 0. """ #: The set of iovs self.__iovs = set() #: Whether or not we raise an error on overlaps self.__allow_overlaps = allow_overlaps #: Whether or not run 1 will be also considered the first run when # combining iovs between experiments self.__allow_startone = allow_startone if iterable is not None: for element in iterable: self.add(element)
[docs] def add(self, iov, allow_overlaps=None): """ Add a new iov to the set. The new iov be combined with existing iovs if possible. After the operation the set will contain the minimal amount of separate iovs possible to represent all added iovs >>> a = IoVSet() >>> a.add((0, 0, 0, 2)) >>> a.add((0, 3, 0, 5)) >>> a.add((0, 8, 0, 9)) >>> a {(0, 0, 0, 5), (0, 8, 0, 9)} >>> a.add(IoVSet([(10, 0, 10, 1), (10, 2, 10, -1)])) >>> a {(0, 0, 0, 5), (0, 8, 0, 9), (10, 0, 10, inf)} Be aware, by default it's not possible to add overlapping iovs to the set. This can be changed either on construction or per `add` call using ``allow_overlap`` >>> a.add((0, 2, 0, 3)) Traceback (most recent call last): ... ValueError: Overlap between (0, 0, 0, 5) and (0, 2, 0, 3) >>> a.add((0, 2, 0, 3), allow_overlaps=True) >>> a {(0, 0, 0, 5), (0, 8, 0, 9), (10, 0, 10, inf)} Parameters: iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or set of IoVs to add to this set allow_overlaps (bool): Can be used to override global overlap setting of this set to allow/restrict overlaps for a single insertion operation Warning: This method modifies the set in place """ # check whether we override overlap settings if allow_overlaps is None: allow_overlaps = self.__allow_overlaps # we can add a set to a set :D if isinstance(iov, IoVSet): for element in iov: self.add(element, allow_overlaps) return # make sure it's actually an IoV, this will raise an error on failure if not isinstance(iov, IntervalOfValidity): iov = IntervalOfValidity(iov) # and now check for all existing iovs ... (but use a copy since we modify the set) for existing in list(self.__iovs): # if there's an overlap to the new iov if (not allow_overlaps) and (existing & iov): raise ValueError(f"Overlap between {existing} and {iov}") # and if they can be combined to a bigger iov combined = existing.union(iov, self.__allow_startone) # if we now have a combined iov, remove the one that we were able to # combine it with from the existing iovs because we now check # against the combined one. Since the only way to add a new iov is # this loop we know all previous existing iovs we checked before # didn't have a union with this new iov or any other existing iovs # so if the just check the remaining iovs against the new combined # one we can cascade combine all iovs in one go. if combined is not None: self.__iovs.remove(existing) iov = combined # done, we now have a new iov which combines all existing iovs it had an # overlap with and we removed the existing iovs so nothing else to do # but add the iov back in the list self.__iovs.add(iov)
[docs] def remove(self, iov): """Remove an iov or a set of iovs from this set After this operation the set will not be valid for the given iov or set of iovs: >>> a = IoVSet() >>> a.add((0,0,10,-1)) >>> a.remove((1,0,1,-1)) >>> a.remove((5,0,8,5)) >>> a {(0, 0, 0, inf), (2, 0, 4, inf), (8, 6, 10, inf)} >>> a.remove(IoVSet([(3,0,3,10), (3,11,3,-1)])) >>> a {(0, 0, 0, inf), (2, 0, 2, inf), (4, 0, 4, inf), (8, 6, 10, inf)} Parameters: iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or set of IoVs to remove from this set Warning: This method modifies the set in place """ # we can remove a set from a set :D if isinstance(iov, IoVSet): for element in iov: self.remove(element) return # make sure it's actually an IoV, this will raise an error on failure if not isinstance(iov, IntervalOfValidity): iov = IntervalOfValidity(iov) # and subtract the iov from all existing iovs for existing in list(self.__iovs): delta = existing - iov if delta != existing: self.__iovs.remove(existing) if isinstance(delta, tuple): # got two new iovs, apparently we split the old one for new in delta: self.__iovs.add(new) elif delta is not None: self.__iovs.add(delta)
[docs] def intersect(self, iov): """Intersect this set with another set and return a new set which is valid exactly where both sets have been valid before >>> a = IoVSet() >>> a.add((0,0,10,-1)) >>> a.intersect((5,0,20,-1)) {(5, 0, 10, inf)} >>> a.intersect(IoVSet([(0,0,3,-1), (9,0,20,-1)])) {(0, 0, 3, inf), (9, 0, 10, inf)} Parameters: iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or set of IoVs to intersect with this set """ if not isinstance(iov, (IoVSet, IntervalOfValidity)): iov = IntervalOfValidity(iov) if isinstance(iov, IntervalOfValidity): iov = IoVSet([iov]) # ok for all combinations a,b from set1 and set2 check the intersection # and if not empty add to the result result = IoVSet() for a, b in product(self.iovs, iov.iovs): c = a & b if c: result.add(c) return result
[docs] def contains(self, iov): """ Check if an iov is fully covered by the set >>> a = IoVSet([(0,0,2,-1), (5,0,5,-1)]) >>> a.contains((0,0,1,-1)) True >>> a.contains(IntervalOfValidity(0,0,3,2)) False >>> a.contains(IoVSet([(0,1,1,23), (5,0,5,23)])) True >>> a.contains(IoVSet([(0,1,1,23), (5,0,6,23)])) False >>> a.contains((3,0,4,-1)) False Parameters: iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or set of IoVs to be checked Returns: True if the full iov or all the iovs in the given set are fully present in this set """ # check if the whole set is in this set: all iovs need to be in here if isinstance(iov, IoVSet): return all(e in self for e in iov) # make sure it's actually an IoV, this will raise an error on failure if not isinstance(iov, IntervalOfValidity): iov = IntervalOfValidity(iov) # and then check all iovs in the set if they cover it for existing in self.__iovs: if iov - existing is None: return True return False
[docs] def overlaps(self, iov): """Check if the given iov overlaps with this set. In contrast to `contains` this doesn't require the given iov to be fully covered. It's enough if the any run covered by the iov is also covered by this set. >>> a = IoVSet([(0,0,2,-1), (5,0,5,-1)]) >>> a.overlaps((0,0,1,-1)) True >>> a.overlaps(IntervalOfValidity(0,0,3,2)) True >>> a.overlaps(IoVSet([(0,1,1,23), (5,0,5,23)])) True >>> a.overlaps(IoVSet([(0,1,1,23), (5,0,6,23)])) True >>> a.overlaps((3,0,4,-1)) False Parameters: iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or set of IoVs to be checked Returns: True if the iov or any of the iovs in the given set overlap with any iov in this set """ if not isinstance(iov, (IoVSet, IntervalOfValidity)): iov = IntervalOfValidity(iov) if isinstance(iov, IntervalOfValidity): iov = IoVSet([iov]) for a, b in product(self.iovs, iov.iovs): c = a & b if c: return True return False
[docs] def copy(self): """Return a copy of this set""" copy = IoVSet(allow_overlaps=self.__allow_overlaps, allow_startone=self.__allow_startone) copy.__iovs = set(self.__iovs) return copy
[docs] def clear(self): """Clear all iovs from this set""" self.__iovs = {}
@property def iovs(self): """Return the set of valid iovs""" return self.__iovs @property def first(self): """Return the first run covered by this iov set >>> a = IoVSet([(3,0,3,10), (10,11,10,23), (0,0,2,-1), (5,0,5,-1)]) >>> a.first (0, 0) """ if not self.__iovs: return None return min(self.iovs).first @property def final(self): """Return the final run covered by this iov set >>> a = IoVSet([(3,0,3,10), (10,11,10,23), (0,0,2,-1), (5,0,5,-1)]) >>> a.final (10, 23) """ if not self.__iovs: return None return max(self.iovs).final @property def gaps(self): """Return the gaps in the set. Any area not covered between the first point of validity and the last >>> a = IoVSet([(0,0,2,-1)]) >>> a.gaps {} >>> b = IoVSet([(0,0,2,-1), (5,0,5,-1)]) >>> b.gaps {(3, 0, 4, inf)} >>> c = IoVSet([(0,0,2,-1), (5,0,5,-1), (10,3,10,6)]) >>> c.gaps {(3, 0, 4, inf), (6, 0, 10, 2)} """ if len(self.__iovs) < 2: return IoVSet() full_range = IoVSet([self.first + self.final]) return full_range - self def __bool__(self): """Return True if the set is not empty >>> a = IoVSet() >>> a.add((0,0,1,-1)) >>> bool(a) True >>> a.clear() >>> a {} >>> bool(a) False """ return len(self.__iovs) > 0 def __contains__(self, iov): """Check if an iov is fully covered by the set""" return self.contains(iov) def __and__(self, other): """Return a new set that is the intersection between two sets >>> a = IoVSet([(0,0,1,-1)]) >>> a & (1,0,2,-1) {(1, 0, 1, inf)} """ return self.intersect(other) def __or__(self, other): """ Return a new set that is the combination of two sets: The new set will be valid everywhere any of the two sets were valid. No check for overlaps will be performed but the result will inherit the settings for further additions from the first set >>> a = IoVSet([(0,0,1,-1)]) >>> a | (1,0,2,-1) {(0, 0, 2, inf)} >>> a | (3,0,3,-1) {(0, 0, 1, inf), (3, 0, 3, inf)} """ copy = self.copy() copy.add(other, allow_overlaps=True) return copy def __sub__(self, other): """ Return a new set which is only valid for where a is valid but not b. See `remove` but this will not modify the set in place >>> a = IoVSet([(0,0,-1,-1)]) >>> a - (1,0,2,-1) {(0, 0, 0, inf), (3, 0, inf, inf)} >>> a - (0,0,3,-1) - (10,0,-1,-1) {(4, 0, 9, inf)} >>> IoVSet([(0,0,1,-1)]) - (2,0,2,-1) {(0, 0, 1, inf)} """ copy = self.copy() copy.remove(other) return copy def __iter__(self): """Loop over the set of iovs""" return iter(self.__iovs) def __len__(self): """Return the number of validity intervals in this set""" return len(self.__iovs) def __repr__(self): """Return a printable representation""" return '{' + ', '.join(str(e) for e in sorted(self.__iovs)) + '}'