Belle II Software development
iov.py
1#!/usr/bin/env python3
2
3
10
11"""
12conditions_db.iov
13-----------------
14
15This module contains classes to work with validity intervals. There's a class
16for a single interval, `IntervalOfValidity` and a class to manage a set of
17validities, `IoVSet`, which can be used to manipulate iov ranges
18"""
19
20import math
21from itertools import product
22
23
25 """
26 Interval of validity class to support set operations like union and
27 intersection.
28
29 An interval of validity is a set of runs for which something is valid. An
30 IntervalOfValidity consists of a `first` valid run and a `final` valid run.
31
32 Warning:
33 The `final` run is inclusive so the the validity is including the final run.
34
35 Each run is identified by a experiment number and a run number. Accessing
36 `first` or `final` will return a tuple ``(experiment, run)`` but the
37 elements can also be accessed separately with `first_exp`, `first_exp`,
38 `final_exp` and `final_run`.
39
40 For `final` there's a special case where either the run or both, the run and
41 the experiment number are infinite. This means the validity extends to all
42 values. If only the run number is infinite then it's valid for all further
43 runs in this experiment. If both are infinite the validity extends to everything.
44
45 For simplicity ``-1`` can be passed in instead of infinity when creating objects.
46 """
47
48 def __init__(self, *iov):
49 """Create a new object.
50
51 It can be either instantiated by providing four values or one tuple/list
52 with four values for first_exp, first_run, final_exp, final_run
53 """
54 if len(iov) == 1 and isinstance(iov[0], (list, tuple)):
55 iov = iov[0]
56 if len(iov) != 4:
57 raise ValueError("A iov should have four values")
58
59 self.__first = tuple(iov[:2])
60
61 self.__final = tuple(math.inf if x == -1 else x for x in iov[2:])
62 if math.isinf(self.__final[0]) and not math.isinf(self.__final[1]):
63 raise ValueError(f"Unlimited final experiment but not unlimited run: {self}")
64 if self.__first[0] > self.__final[0]:
65 raise ValueError(f"First exp larger than final exp: {self}")
66 if self.__first[0] == self.__final[0] and self.__first[1] > self.__final[1]:
67 raise ValueError(f"First run larger than final run: {self}")
68 if self.__first[0] < 0 or self.__first[1] < 0:
69 raise ValueError(f"Negative first exp or run: {self}")
70
71 @staticmethod
72 def always():
73 """Return an iov that is valid everywhere
74
75 >>> IntervalOfValidity.always()
76 (0, 0, inf, inf)
77 """
78 return IntervalOfValidity(0, 0, -1, -1)
79
80 @property
81 def first(self):
82 """Return the first valid experiment,run"""
83 return self.__first
84
85 @property
86 def first_exp(self):
87 """Return the first valid experiment"""
88 return self.__first[0]
89
90 @property
91 def first_run(self):
92 """Return the first valid run"""
93 return self.__first[1]
94
95 @property
96 def final(self):
97 """Return the final valid experiment,run"""
98 return self.__final
99
100 @property
101 def final_exp(self):
102 """Return the final valid experiment"""
103 return self.__final[0]
104
105 @property
106 def final_run(self):
107 """Return the final valid run"""
108 return self.__final[1]
109
110 def __repr__(self):
111 """Return a printable representation"""
112 return str(self.__first + self.__final)
113
114 def __eq__(self, other):
115 """Check for equality"""
116 if not isinstance(other, IntervalOfValidity):
117 return NotImplemented
118 return (self.__first, self.__final) == (other.__first, other.__final)
119
120 def __lt__(self, other):
121 """Sort by run values"""
122 if not isinstance(other, IntervalOfValidity):
123 return NotImplemented
124 return (self.__first, self.__final) < (other.__first, other.__final)
125
126 def __and__(self, other):
127 """Intersection between iovs. Will return None if the payloads don't overlap"""
128 if not isinstance(other, IntervalOfValidity):
129 return NotImplemented
130 return self.intersect(other)
131
132 def __or__(self, other):
133 """Union between iovs. Will return None if the iovs don't overlap or
134 connect to each other"""
135 if not isinstance(other, IntervalOfValidity):
136 return NotImplemented
137 return self.union(other, False)
138
139 def __sub__(self, other):
140 """Difference between iovs. Will return None if nothing is left over"""
141 if not isinstance(other, IntervalOfValidity):
142 return NotImplemented
143 return self.subtract(other)
144
145 def __hash__(self):
146 """Make object hashable"""
147 return hash((self.__first, self.__final))
148
149 def subtract(self, other):
150 """Return a new iov with the validity of the other removed.
151 Will return None if everything is removed.
152
153 Warning:
154 If the other iov is in the middle of the validity we will return a
155 tuple of two new iovs
156
157 >>> iov1 = IntervalOfValidity(0,0,10,-1)
158 >>> iov2 = IntervalOfValidity(5,0,5,-1)
159 >>> iov1 - iov2
160 ((0, 0, 4, inf), (6, 0, 10, inf))
161 """
162 if other.first <= self.first and other.final >= self.final:
163 # full overlap
164 return None
165 if other.first > self.first and other.final < self.final:
166 # the one we want to remove is in the middle, return a pair of iovs
167 # by subtracting two extended once
168 iov1 = self.subtract(IntervalOfValidity(other.first + (-1, -1)))
169 iov2 = self.subtract(IntervalOfValidity((0, 0) + other.final))
170 return (iov1, iov2)
171 if other.first <= self.final and other.final >= self.first:
172 # one sided overlap, figure out which side and calculate the remainder
173 if self.first < other.first:
174 end_run = other.first_run - 1
175 end_exp = other.first_exp if end_run >= 0 else other.first_exp - 1
176 return IntervalOfValidity(self.first + (end_exp, end_run))
177 else:
178 start_run = other.final_run + 1
179 start_exp = other.final_exp
180 if math.isinf(other.final_run):
181 start_exp += 1
182 start_run = 0
183 return IntervalOfValidity((start_exp, start_run) + self.final)
184 # no overlap so return unchanged
185 return self
186
187 def intersect(self, other):
188 """Intersection with another iov.
189
190 Will return None if the payloads don't overlap
191
192 >>> iov1 = IntervalOfValidity(1,0,2,5)
193 >>> iov2 = IntervalOfValidity(2,0,2,-1)
194 >>> iov3 = IntervalOfValidity(2,10,5,-1)
195 >>> iov1.intersect(iov2)
196 (2, 0, 2, 5)
197 >>> iov2.intersect(iov3)
198 (2, 10, 2, inf)
199 >>> iov3.intersect(iov1) is None
200 True
201
202 """
203 # \cond false positive doxygen warning
204 if other.first <= self.final and other.final >= self.first:
205 return IntervalOfValidity(*(max(self.first, other.first) + min(self.final, other.final)))
206 return None
207 # \endcond
208
209 def union(self, other, allow_startone=False):
210 """
211 Return the union with another iov.
212
213 >>> iov1 = IntervalOfValidity(1,0,1,-1)
214 >>> iov2 = IntervalOfValidity(2,0,2,-1)
215 >>> iov3 = IntervalOfValidity(2,10,5,-1)
216 >>> iov1.union(iov2)
217 (1, 0, 2, inf)
218 >>> iov2.union(iov3)
219 (2, 0, 5, inf)
220 >>> iov3.union(iov1) is None
221 True
222
223 Warning:
224 This method will return None if the iovs don't overlap or connect to
225 each other as no union can be formed.
226
227 Parameters:
228 other (IntervalOfValidity): IoV to calculate the union with
229 allow_startone (bool): If True we will consider run 0 and run 1 the
230 first run in an experiment. This means that if one of the iovs has
231 un unlimited final run it can be joined with the other iov if the
232 experiment number increases and the iov starts at run 0 and 1. If
233 this is False just run 0 is considered the next run.
234
235 >>> iov1 = IntervalOfValidity(0,0,0,-1)
236 >>> iov2 = IntervalOfValidity(1,1,1,-1)
237 >>> iov1.union(iov2, False) is None
238 True
239 >>> iov1.union(iov2, True)
240 (0, 0, 1, inf)
241
242 """
243 # \cond false positive doxygen warning
244 # check the trivial case of overlapping
245 if other.first <= self.final and other.final >= self.first:
246 return IntervalOfValidity(min(self.first, other.first) + max(self.final, other.final))
247 # \endcond
248 # ok, let's do the less simple case where they don't overlap but join directly
249 for i1, i2 in (self, other), (other, self):
250 if (i1.first == (i2.final_exp, i2.final_run + 1) or
251 (math.isinf(i2.final_run) and (i1.first_exp == i2.final_exp + 1) and
252 (i1.first_run == 0 or allow_startone and i1.first_run == 1))):
253 return IntervalOfValidity(i2.first + i1.final)
254 # no union possible: not directly connected and not overlapping
255 return None
256
257 def contains(self, exp, run):
258 """Check if a run is part of the validity"""
259 return self.first <= (exp, run) <= self.final
260
261 @property
262 def is_open(self):
263 """Check whether the iov is valid until infinity"""
264 ## Doxygen complains without this string.
265 return self.final == (math.inf, math.inf)
266
267 @property
268 def tuple(self):
269 """Return the iov as a tuple with experiment/run numbers replaced with -1
270
271 This is mostly helpful where infinity is not supported and is how the
272 intervals are represented in the database.
273
274 >>> a = IntervalOfValidity.always()
275 >>> a
276 (0, 0, inf, inf)
277 >>> a.tuple
278 (0, 0, -1, -1)
279 """
280 return self.__first + tuple(-1 if math.isinf(x) else x for x in self.__final)
281
282
283class IoVSet:
284 """A set of iovs.
285
286 This class allows to combine iovs into a set. New iovs can be added with
287 `add()` and will be combined with existing iovs if possible.
288
289 The final, minimal number of iovs can be obtained with the `iovs` property
290
291 >>> a = IoVSet()
292 >>> a.add((0,0,0,2))
293 >>> a.add((0,3,0,5))
294 >>> a.add((0,8,0,9))
295 >>> a
296 {(0, 0, 0, 5), (0, 8, 0, 9)}
297 """
298
299 def __init__(self, iterable=None, *, allow_overlaps=False, allow_startone=False):
300 """Create a new set.
301
302 >>> a = IoVSet([IntervalOfValidity(3,6,3,-1), (0,0,3,5)])
303 >>> a
304 {(0, 0, 3, inf)}
305
306 Parameters:
307 iterable: if not None it should be an iterable of IntervalOfValidity
308 objects or anything that can be converted to an IntervalOfValidity.
309 allow_overlaps (bool): If False adding which overlaps with any
310 existing iov in the set will raise a ValueError.
311 allow_startone (bool): If True also join iovs if one covers the
312 whole experiment and the next one starts at run 1 in the next
313 experiment. If False they will only be joined if the next one
314 starts at run 0.
315 """
316 ## The set of iovs
317 self.__iovs = set()
318 ## Whether or not we raise an error on overlaps
319 self.__allow_overlaps = allow_overlaps
320 ## Whether or not run 1 will be also considered the first run when
321 # combining iovs between experiments
322 self.__allow_startone = allow_startone
323 if iterable is not None:
324 for element in iterable:
325 self.add(element)
326
327 def add(self, iov, allow_overlaps=None):
328 """
329 Add a new iov to the set.
330
331 The new iov be combined with existing iovs if possible. After the
332 operation the set will contain the minimal amount of separate iovs
333 possible to represent all added iovs
334
335 >>> a = IoVSet()
336 >>> a.add((0, 0, 0, 2))
337 >>> a.add((0, 3, 0, 5))
338 >>> a.add((0, 8, 0, 9))
339 >>> a
340 {(0, 0, 0, 5), (0, 8, 0, 9)}
341 >>> a.add(IoVSet([(10, 0, 10, 1), (10, 2, 10, -1)]))
342 >>> a
343 {(0, 0, 0, 5), (0, 8, 0, 9), (10, 0, 10, inf)}
344
345 Be aware, by default it's not possible to add overlapping iovs to the set.
346 This can be changed either on construction or per `add` call using
347 ``allow_overlap``
348
349 >>> a.add((0, 2, 0, 3))
350 Traceback (most recent call last):
351 ...
352 ValueError: Overlap between (0, 0, 0, 5) and (0, 2, 0, 3)
353 >>> a.add((0, 2, 0, 3), allow_overlaps=True)
354 >>> a
355 {(0, 0, 0, 5), (0, 8, 0, 9), (10, 0, 10, inf)}
356
357 Parameters:
358 iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or
359 set of IoVs to add to this set
360 allow_overlaps (bool): Can be used to override global overlap setting
361 of this set to allow/restrict overlaps for a single insertion
362 operation
363
364 Warning:
365 This method modifies the set in place
366 """
367 # check whether we override overlap settings
368 if allow_overlaps is None:
369 allow_overlaps = self.__allow_overlaps
370 # we can add a set to a set :D
371 if isinstance(iov, IoVSet):
372 for element in iov:
373 self.add(element, allow_overlaps)
374 return
375 # make sure it's actually an IoV, this will raise an error on failure
376 if not isinstance(iov, IntervalOfValidity):
377 iov = IntervalOfValidity(iov)
378 # and now check for all existing iovs ... (but use a copy since we modify the set)
379 for existing in list(self.__iovs):
380 # if there's an overlap to the new iov
381 if (not allow_overlaps) and (existing & iov):
382 raise ValueError(f"Overlap between {existing} and {iov}")
383 # and if they can be combined to a bigger iov
384 combined = existing.union(iov, self.__allow_startone)
385 # if we now have a combined iov, remove the one that we were able to
386 # combine it with from the existing iovs because we now check
387 # against the combined one. Since the only way to add a new iov is
388 # this loop we know all previous existing iovs we checked before
389 # didn't have a union with this new iov or any other existing iovs
390 # so if the just check the remaining iovs against the new combined
391 # one we can cascade combine all iovs in one go.
392 if combined is not None:
393 self.__iovs.remove(existing)
394 iov = combined
395 # done, we now have a new iov which combines all existing iovs it had an
396 # overlap with and we removed the existing iovs so nothing else to do
397 # but add the iov back in the list
398 self.__iovs.add(iov)
399
400 def remove(self, iov):
401 """Remove an iov or a set of iovs from this set
402
403 After this operation the set will not be valid for the given iov or set
404 of iovs:
405
406 >>> a = IoVSet()
407 >>> a.add((0,0,10,-1))
408 >>> a.remove((1,0,1,-1))
409 >>> a.remove((5,0,8,5))
410 >>> a
411 {(0, 0, 0, inf), (2, 0, 4, inf), (8, 6, 10, inf)}
412 >>> a.remove(IoVSet([(3,0,3,10), (3,11,3,-1)]))
413 >>> a
414 {(0, 0, 0, inf), (2, 0, 2, inf), (4, 0, 4, inf), (8, 6, 10, inf)}
415
416 Parameters:
417 iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or
418 set of IoVs to remove from this set
419
420 Warning:
421 This method modifies the set in place
422 """
423 # we can remove a set from a set :D
424 if isinstance(iov, IoVSet):
425 for element in iov:
426 self.remove(element)
427 return
428 # make sure it's actually an IoV, this will raise an error on failure
429 if not isinstance(iov, IntervalOfValidity):
430 iov = IntervalOfValidity(iov)
431 # and subtract the iov from all existing iovs
432 for existing in list(self.__iovs):
433 delta = existing - iov
434 if delta != existing:
435 self.__iovs.remove(existing)
436 if isinstance(delta, tuple):
437 # got two new iovs, apparently we split the old one
438 for new in delta:
439 self.__iovs.add(new)
440 elif delta is not None:
441 self.__iovs.add(delta)
442
443 def intersect(self, iov):
444 """Intersect this set with another set and return a new set
445 which is valid exactly where both sets have been valid before
446
447 >>> a = IoVSet()
448 >>> a.add((0,0,10,-1))
449 >>> a.intersect((5,0,20,-1))
450 {(5, 0, 10, inf)}
451 >>> a.intersect(IoVSet([(0,0,3,-1), (9,0,20,-1)]))
452 {(0, 0, 3, inf), (9, 0, 10, inf)}
453
454 Parameters:
455 iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or
456 set of IoVs to intersect with this set
457 """
458 if not isinstance(iov, (IoVSet, IntervalOfValidity)):
459 iov = IntervalOfValidity(iov)
460 if isinstance(iov, IntervalOfValidity):
461 iov = IoVSet([iov])
462
463 # ok for all combinations a,b from set1 and set2 check the intersection
464 # and if not empty add to the result
465 result = IoVSet()
466 # \cond false positive doxygen warning
467 for a, b in product(self.iovs, iov.iovs):
468 c = a & b
469 if c:
470 result.add(c)
471 return result
472 # \endcond
473
474 def contains(self, iov):
475 """
476 Check if an iov is fully covered by the set
477
478 >>> a = IoVSet([(0,0,2,-1), (5,0,5,-1)])
479 >>> a.contains((0,0,1,-1))
480 True
481 >>> a.contains(IntervalOfValidity(0,0,3,2))
482 False
483 >>> a.contains(IoVSet([(0,1,1,23), (5,0,5,23)]))
484 True
485 >>> a.contains(IoVSet([(0,1,1,23), (5,0,6,23)]))
486 False
487 >>> a.contains((3,0,4,-1))
488 False
489
490 Parameters:
491 iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or
492 set of IoVs to be checked
493
494 Returns:
495 True if the full iov or all the iovs in the given set are fully
496 present in this set
497 """
498 # check if the whole set is in this set: all iovs need to be in here
499 if isinstance(iov, IoVSet):
500 return all(e in self for e in iov)
501 # make sure it's actually an IoV, this will raise an error on failure
502 if not isinstance(iov, IntervalOfValidity):
503 iov = IntervalOfValidity(iov)
504 # and then check all iovs in the set if they cover it
505 for existing in self.__iovs:
506 if iov - existing is None:
507 return True
508 return False
509
510 def overlaps(self, iov):
511 """Check if the given iov overlaps with this set.
512
513 In contrast to `contains` this doesn't require the given iov to be fully
514 covered. It's enough if the any run covered by the iov is also covered
515 by this set.
516
517 >>> a = IoVSet([(0,0,2,-1), (5,0,5,-1)])
518 >>> a.overlaps((0,0,1,-1))
519 True
520 >>> a.overlaps(IntervalOfValidity(0,0,3,2))
521 True
522 >>> a.overlaps(IoVSet([(0,1,1,23), (5,0,5,23)]))
523 True
524 >>> a.overlaps(IoVSet([(0,1,1,23), (5,0,6,23)]))
525 True
526 >>> a.overlaps((3,0,4,-1))
527 False
528
529 Parameters:
530 iov (Union[IoVSet, IntervalOfValidity, tuple(int)]): IoV or
531 set of IoVs to be checked
532
533 Returns:
534 True if the iov or any of the iovs in the given set overlap with any
535 iov in this set
536 """
537 if not isinstance(iov, (IoVSet, IntervalOfValidity)):
538 iov = IntervalOfValidity(iov)
539 if isinstance(iov, IntervalOfValidity):
540 iov = IoVSet([iov])
541
542 # \cond false positive doxygen warning
543 for a, b in product(self.iovs, iov.iovs):
544 c = a & b
545 if c:
546 return True
547 return False
548 # \endcond
549
550 def copy(self):
551 """Return a copy of this set"""
552 copy = IoVSet(allow_overlaps=self.__allow_overlaps, allow_startone=self.__allow_startone)
553 copy.__iovs = set(self.__iovs)
554 return copy
555
556 def clear(self):
557 """Clear all iovs from this set"""
558 self.__iovs = {}
559
560 @property
561 def iovs(self):
562 """Return the set of valid iovs"""
563 return self.__iovs
564
565 # \cond false positive doxygen warning
566 @property
567 def first(self):
568 """Return the first run covered by this iov set
569
570 >>> a = IoVSet([(3,0,3,10), (10,11,10,23), (0,0,2,-1), (5,0,5,-1)])
571 >>> a.first
572 (0, 0)
573 """
574 if not self.__iovs:
575 return None
576 return min(self.iovs).first
577
578 @property
579 def final(self):
580 """Return the final run covered by this iov set
581
582 >>> a = IoVSet([(3,0,3,10), (10,11,10,23), (0,0,2,-1), (5,0,5,-1)])
583 >>> a.final
584 (10, 23)
585 """
586 if not self.__iovs:
587 return None
588 return max(self.iovs).final
589 # \endcond
590
591 @property
592 def gaps(self):
593 """Return the gaps in the set. Any area not covered between the first
594 point of validity and the last
595
596 >>> a = IoVSet([(0,0,2,-1)])
597 >>> a.gaps
598 {}
599 >>> b = IoVSet([(0,0,2,-1), (5,0,5,-1)])
600 >>> b.gaps
601 {(3, 0, 4, inf)}
602 >>> c = IoVSet([(0,0,2,-1), (5,0,5,-1), (10,3,10,6)])
603 >>> c.gaps
604 {(3, 0, 4, inf), (6, 0, 10, 2)}
605 """
606 if len(self.__iovs) < 2:
607 return IoVSet()
608
609 full_range = IoVSet([self.first + self.final])
610 return full_range - self
611
612 def __bool__(self):
613 """Return True if the set is not empty
614
615
616 >>> a = IoVSet()
617 >>> a.add((0,0,1,-1))
618 >>> bool(a)
619 True
620 >>> a.clear()
621 >>> a
622 {}
623 >>> bool(a)
624 False
625 """
626 return len(self.__iovs) > 0
627
628 def __contains__(self, iov):
629 """Check if an iov is fully covered by the set"""
630 return self.contains(iov)
631
632 def __and__(self, other):
633 """Return a new set that is the intersection between two sets
634
635 >>> a = IoVSet([(0,0,1,-1)])
636 >>> a & (1,0,2,-1)
637 {(1, 0, 1, inf)}
638 """
639 return self.intersect(other)
640
641 def __or__(self, other):
642 """
643 Return a new set that is the combination of two sets: The new set will
644 be valid everywhere any of the two sets were valid.
645
646 No check for overlaps will be performed but the result will inherit the
647 settings for further additions from the first set
648
649 >>> a = IoVSet([(0,0,1,-1)])
650 >>> a | (1,0,2,-1)
651 {(0, 0, 2, inf)}
652 >>> a | (3,0,3,-1)
653 {(0, 0, 1, inf), (3, 0, 3, inf)}
654 """
655 copy = self.copy()
656 copy.add(other, allow_overlaps=True)
657 return copy
658
659 def __sub__(self, other):
660 """
661 Return a new set which is only valid for where a is valid but not b.
662
663 See `remove` but this will not modify the set in place
664
665 >>> a = IoVSet([(0,0,-1,-1)])
666 >>> a - (1,0,2,-1)
667 {(0, 0, 0, inf), (3, 0, inf, inf)}
668 >>> a - (0,0,3,-1) - (10,0,-1,-1)
669 {(4, 0, 9, inf)}
670 >>> IoVSet([(0,0,1,-1)]) - (2,0,2,-1)
671 {(0, 0, 1, inf)}
672 """
673 copy = self.copy()
674 copy.remove(other)
675 return copy
676
677 def __iter__(self):
678 """Loop over the set of iovs"""
679 return iter(self.__iovs)
680
681 def __len__(self):
682 """Return the number of validity intervals in this set"""
683 return len(self.__iovs)
684
685 def __repr__(self):
686 """Return a printable representation"""
687 return '{' + ', '.join(str(e) for e in sorted(self.__iovs)) + '}'
tuple final
Doxygen complains without this string.
Definition iov.py:165
__first
tuple with the first valid exp, run
Definition iov.py:59
__final
tuple with the final valid exp, run
Definition iov.py:61