Coverage for python/lsst/daf/butler/_timespan.py: 27%
182 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-16 10:44 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-16 10:44 +0000
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
27from __future__ import annotations
29__all__ = ("Timespan",)
31import enum
32import warnings
33from collections.abc import Generator
34from typing import TYPE_CHECKING, Any, ClassVar, Union
36import astropy.time
37import astropy.utils.exceptions
38import yaml
40# As of astropy 4.2, the erfa interface is shipped independently and
41# ErfaWarning is no longer an AstropyWarning
42try:
43 import erfa
44except ImportError:
45 erfa = None
47from lsst.utils.classes import cached_getter
49from .json import from_json_generic, to_json_generic
50from .time_utils import TimeConverter
52if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
53 from .dimensions import DimensionUniverse
54 from .registry import Registry
57class _SpecialTimespanBound(enum.Enum):
58 """Enumeration to provide a singleton value for empty timespan bounds.
60 This enum's only member should generally be accessed via the
61 `Timespan.EMPTY` alias.
62 """
64 EMPTY = enum.auto()
65 """The value used for both `Timespan.begin` and `Timespan.end` for empty
66 Timespans that contain no points.
67 """
70TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
73class Timespan:
74 """A half-open time interval with nanosecond precision.
76 Parameters
77 ----------
78 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
79 Minimum timestamp in the interval (inclusive). `None` indicates that
80 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
81 timespan contains no times; if this is used as either bound, the other
82 bound is ignored.
83 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
84 Maximum timestamp in the interval (exclusive). `None` indicates that
85 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
86 creates an empty timespan.
87 padInstantaneous : `bool`, optional
88 If `True` (default) and ``begin == end`` *after discretization to
89 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
90 finite-duration timespan. If `False`, ``begin == end`` evaluates to
91 the empty timespan.
92 _nsec : `tuple` of `int`, optional
93 Integer nanosecond representation, for internal use by `Timespan` and
94 `TimespanDatabaseRepresentation` implementation only. If provided,
95 all other arguments are are ignored.
97 Raises
98 ------
99 TypeError
100 Raised if ``begin`` or ``end`` has a type other than
101 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
102 ValueError
103 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
104 supported by this class.
106 Notes
107 -----
108 Timespans are half-open intervals, i.e. ``[begin, end)``.
110 Any timespan with ``begin > end`` after nanosecond discretization
111 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
112 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
113 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
114 contained by all other timespans (including itself). It is also disjoint
115 with all timespans (including itself), and hence does not overlap any
116 timespan - this is the only case where ``contains`` does not imply
117 ``overlaps``.
119 Finite timespan bounds are represented internally as integer nanoseconds,
120 and hence construction from `astropy.time.Time` (which has picosecond
121 accuracy) can involve a loss of precision. This is of course
122 deterministic, so any `astropy.time.Time` value is always mapped
123 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
124 timespans that are empty at full precision (``begin > end``,
125 ``begin - end < 1ns``) may be finite after discretization. In all other
126 cases, the relationships between full-precision timespans should be
127 preserved even if the values are not.
129 The `astropy.time.Time` bounds that can be obtained after construction from
130 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
131 exactly when used to construct other `Timespan` instances.
132 """
134 def __init__(
135 self,
136 begin: TimespanBound,
137 end: TimespanBound,
138 padInstantaneous: bool = True,
139 _nsec: tuple[int, int] | None = None,
140 ):
141 converter = TimeConverter()
142 if _nsec is None:
143 begin_nsec: int
144 if begin is None:
145 begin_nsec = converter.min_nsec
146 elif begin is self.EMPTY:
147 begin_nsec = converter.max_nsec
148 elif isinstance(begin, astropy.time.Time):
149 begin_nsec = converter.astropy_to_nsec(begin)
150 else:
151 raise TypeError(
152 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
153 )
154 end_nsec: int
155 if end is None:
156 end_nsec = converter.max_nsec
157 elif end is self.EMPTY:
158 end_nsec = converter.min_nsec
159 elif isinstance(end, astropy.time.Time):
160 end_nsec = converter.astropy_to_nsec(end)
161 else:
162 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.")
163 if begin_nsec == end_nsec:
164 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
165 with warnings.catch_warnings():
166 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
167 if erfa is not None:
168 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
169 if begin is not None and begin < converter.epoch:
170 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
171 if end is not None and end > converter.max_time:
172 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
173 raise ValueError("Infinite instantaneous timespans are not supported.")
174 elif padInstantaneous:
175 end_nsec += 1
176 if end_nsec == converter.max_nsec:
177 raise ValueError(
178 f"Cannot construct near-instantaneous timespan at {end}; "
179 "within one ns of maximum time."
180 )
181 _nsec = (begin_nsec, end_nsec)
182 if _nsec[0] >= _nsec[1]:
183 # Standardizing all empty timespans to the same underlying values
184 # here simplifies all other operations (including interactions
185 # with TimespanDatabaseRepresentation implementations).
186 _nsec = (converter.max_nsec, converter.min_nsec)
187 self._nsec = _nsec
189 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
191 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
193 # YAML tag name for Timespan
194 yaml_tag = "!lsst.daf.butler.Timespan"
196 @classmethod
197 def makeEmpty(cls) -> Timespan:
198 """Construct an empty timespan.
200 Returns
201 -------
202 empty : `Timespan`
203 A timespan that is contained by all timespans (including itself)
204 and overlaps no other timespans (including itself).
205 """
206 converter = TimeConverter()
207 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
209 @classmethod
210 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
211 """Construct a timespan that approximates an instant in time.
213 This is done by constructing a minimum-possible (1 ns) duration
214 timespan.
216 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
217 but may be slightly more efficient.
219 Parameters
220 ----------
221 time : `astropy.time.Time`
222 Time to use for the lower bound.
224 Returns
225 -------
226 instant : `Timespan`
227 A ``[time, time + 1ns)`` timespan.
228 """
229 converter = TimeConverter()
230 nsec = converter.astropy_to_nsec(time)
231 if nsec == converter.max_nsec - 1:
232 raise ValueError(
233 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time."
234 )
235 return Timespan(None, None, _nsec=(nsec, nsec + 1))
237 @property
238 @cached_getter
239 def begin(self) -> TimespanBound:
240 """Minimum timestamp in the interval, inclusive.
242 If this bound is finite, this is an `astropy.time.Time` instance.
243 If the timespan is unbounded from below, this is `None`.
244 If the timespan is empty, this is the special value `Timespan.EMPTY`.
245 """
246 if self.isEmpty():
247 return self.EMPTY
248 elif self._nsec[0] == TimeConverter().min_nsec:
249 return None
250 else:
251 return TimeConverter().nsec_to_astropy(self._nsec[0])
253 @property
254 @cached_getter
255 def end(self) -> TimespanBound:
256 """Maximum timestamp in the interval, exclusive.
258 If this bound is finite, this is an `astropy.time.Time` instance.
259 If the timespan is unbounded from above, this is `None`.
260 If the timespan is empty, this is the special value `Timespan.EMPTY`.
261 """
262 if self.isEmpty():
263 return self.EMPTY
264 elif self._nsec[1] == TimeConverter().max_nsec:
265 return None
266 else:
267 return TimeConverter().nsec_to_astropy(self._nsec[1])
269 def isEmpty(self) -> bool:
270 """Test whether ``self`` is the empty timespan (`bool`)."""
271 return self._nsec[0] >= self._nsec[1]
273 def __str__(self) -> str:
274 if self.isEmpty():
275 return "(empty)"
276 fmt = "%Y-%m-%dT%H:%M:%S"
277 # Trap dubious year warnings in case we have timespans from
278 # simulated data in the future
279 with warnings.catch_warnings():
280 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
281 if erfa is not None:
282 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
283 if self.begin is None:
284 head = "(-∞, "
285 else:
286 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
287 head = f"[{self.begin.tai.strftime(fmt)}, "
288 if self.end is None:
289 tail = "∞)"
290 else:
291 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
292 tail = f"{self.end.tai.strftime(fmt)})"
293 return head + tail
295 def __repr__(self) -> str:
296 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
297 # simulate our own here to make Timespan's __repr__ eval-friendly.
298 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
299 # eval-friendly __repr__.
300 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
301 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
302 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
303 return f"Timespan(begin={begin}, end={end})"
305 def __eq__(self, other: Any) -> bool:
306 if not isinstance(other, Timespan):
307 return False
308 # Correctness of this simple implementation depends on __init__
309 # standardizing all empty timespans to a single value.
310 return self._nsec == other._nsec
312 def __hash__(self) -> int:
313 # Correctness of this simple implementation depends on __init__
314 # standardizing all empty timespans to a single value.
315 return hash(self._nsec)
317 def __reduce__(self) -> tuple:
318 return (Timespan, (None, None, False, self._nsec))
320 def __lt__(self, other: astropy.time.Time | Timespan) -> bool:
321 """Test if a Timespan's bounds are strictly less than the given time.
323 Parameters
324 ----------
325 other : `Timespan` or `astropy.time.Time`.
326 Timespan or instant in time to relate to ``self``.
328 Returns
329 -------
330 less : `bool`
331 The result of the less-than test. `False` if either operand is
332 empty.
333 """
334 # First term in each expression below is the "normal" one; the second
335 # ensures correct behavior for empty timespans. It's important that
336 # the second uses a strict inequality to make sure inf == inf isn't in
337 # play, and it's okay for the second to use a strict inequality only
338 # because we know non-empty Timespans have nonzero duration, and hence
339 # the second term is never false for non-empty timespans unless the
340 # first term is also false.
341 if isinstance(other, astropy.time.Time):
342 nsec = TimeConverter().astropy_to_nsec(other)
343 return self._nsec[1] <= nsec and self._nsec[0] < nsec
344 else:
345 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
347 def __gt__(self, other: astropy.time.Time | Timespan) -> bool:
348 """Test if a Timespan's bounds are strictly greater than given time.
350 Parameters
351 ----------
352 other : `Timespan` or `astropy.time.Time`.
353 Timespan or instant in time to relate to ``self``.
355 Returns
356 -------
357 greater : `bool`
358 The result of the greater-than test. `False` if either operand is
359 empty.
360 """
361 # First term in each expression below is the "normal" one; the second
362 # ensures correct behavior for empty timespans. It's important that
363 # the second uses a strict inequality to make sure inf == inf isn't in
364 # play, and it's okay for the second to use a strict inequality only
365 # because we know non-empty Timespans have nonzero duration, and hence
366 # the second term is never false for non-empty timespans unless the
367 # first term is also false.
368 if isinstance(other, astropy.time.Time):
369 nsec = TimeConverter().astropy_to_nsec(other)
370 return self._nsec[0] > nsec and self._nsec[1] > nsec
371 else:
372 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
374 def overlaps(self, other: Timespan | astropy.time.Time) -> bool:
375 """Test if the intersection of this Timespan with another is empty.
377 Parameters
378 ----------
379 other : `Timespan` or `astropy.time.Time`
380 Timespan or time to relate to ``self``. If a single time, this is
381 a synonym for `contains`.
383 Returns
384 -------
385 overlaps : `bool`
386 The result of the overlap test.
388 Notes
389 -----
390 If either ``self`` or ``other`` is empty, the result is always `False`.
391 In all other cases, ``self.contains(other)`` being `True` implies that
392 ``self.overlaps(other)`` is also `True`.
393 """
394 if isinstance(other, astropy.time.Time):
395 return self.contains(other)
396 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
398 def contains(self, other: astropy.time.Time | Timespan) -> bool:
399 """Test if the supplied timespan is within this one.
401 Tests whether the intersection of this timespan with another timespan
402 or point is equal to the other one.
404 Parameters
405 ----------
406 other : `Timespan` or `astropy.time.Time`
407 Timespan or instant in time to relate to ``self``.
409 Returns
410 -------
411 overlaps : `bool`
412 The result of the contains test.
414 Notes
415 -----
416 If ``other`` is empty, `True` is always returned. In all other cases,
417 ``self.contains(other)`` being `True` implies that
418 ``self.overlaps(other)`` is also `True`.
420 Testing whether an instantaneous `astropy.time.Time` value is contained
421 in a timespan is not equivalent to testing a timespan constructed via
422 `Timespan.fromInstant`, because Timespan cannot exactly represent
423 zero-duration intervals. In particular, ``[a, b)`` contains the time
424 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
425 by `Timespan.fromInstant(b)``.
426 """
427 if isinstance(other, astropy.time.Time):
428 nsec = TimeConverter().astropy_to_nsec(other)
429 return self._nsec[0] <= nsec and self._nsec[1] > nsec
430 else:
431 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
433 def intersection(self, *args: Timespan) -> Timespan:
434 """Return a new `Timespan` that is contained by all of the given ones.
436 Parameters
437 ----------
438 *args
439 All positional arguments are `Timespan` instances.
441 Returns
442 -------
443 intersection : `Timespan`
444 The intersection timespan.
445 """
446 if not args:
447 return self
448 lowers = [self._nsec[0]]
449 lowers.extend(ts._nsec[0] for ts in args)
450 uppers = [self._nsec[1]]
451 uppers.extend(ts._nsec[1] for ts in args)
452 nsec = (max(*lowers), min(*uppers))
453 return Timespan(begin=None, end=None, _nsec=nsec)
455 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
456 """Return the one or two timespans that cover the interval(s).
458 The interval is defined as one that is in ``self`` but not ``other``.
460 This is implemented as a generator because the result may be zero, one,
461 or two `Timespan` objects, depending on the relationship between the
462 operands.
464 Parameters
465 ----------
466 other : `Timespan`
467 Timespan to subtract.
469 Yields
470 ------
471 result : `Timespan`
472 A `Timespan` that is contained by ``self`` but does not overlap
473 ``other``. Guaranteed not to be empty.
474 """
475 intersection = self.intersection(other)
476 if intersection.isEmpty():
477 yield self
478 elif intersection == self:
479 yield from ()
480 else:
481 if intersection._nsec[0] > self._nsec[0]:
482 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
483 if intersection._nsec[1] < self._nsec[1]:
484 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
486 def to_simple(self, minimal: bool = False) -> list[int]:
487 """Return simple python type form suitable for serialization.
489 Parameters
490 ----------
491 minimal : `bool`, optional
492 Use minimal serialization. Has no effect on for this class.
494 Returns
495 -------
496 simple : `list` of `int`
497 The internal span as integer nanoseconds.
498 """
499 # Return the internal nanosecond form rather than astropy ISO string
500 return list(self._nsec)
502 @classmethod
503 def from_simple(
504 cls,
505 simple: list[int] | None,
506 universe: DimensionUniverse | None = None,
507 registry: Registry | None = None,
508 ) -> Timespan | None:
509 """Construct a new object from simplified form.
511 Designed to use the data returned from the `to_simple` method.
513 Parameters
514 ----------
515 simple : `list` of `int`, or `None`
516 The values returned by `to_simple()`.
517 universe : `DimensionUniverse`, optional
518 Unused.
519 registry : `lsst.daf.butler.Registry`, optional
520 Unused.
522 Returns
523 -------
524 result : `Timespan` or `None`
525 Newly-constructed object.
526 """
527 if simple is None:
528 return None
529 nsec1, nsec2 = simple # for mypy
530 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
532 to_json = to_json_generic
533 from_json: ClassVar = classmethod(from_json_generic)
535 @classmethod
536 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
537 """Convert Timespan into YAML format.
539 This produces a scalar node with a tag "!_SpecialTimespanBound" and
540 value being a name of _SpecialTimespanBound enum.
542 Parameters
543 ----------
544 dumper : `yaml.Dumper`
545 YAML dumper instance.
546 timespan : `Timespan`
547 Data to be converted.
548 """
549 if timespan.isEmpty():
550 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
551 else:
552 return dumper.represent_mapping(
553 cls.yaml_tag,
554 dict(begin=timespan.begin, end=timespan.end),
555 )
557 @classmethod
558 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None:
559 """Convert YAML node into _SpecialTimespanBound.
561 Parameters
562 ----------
563 loader : `yaml.SafeLoader`
564 Instance of YAML loader class.
565 node : `yaml.ScalarNode`
566 YAML node.
568 Returns
569 -------
570 value : `Timespan`
571 Timespan instance, can be ``None``.
572 """
573 if node.value is None:
574 return None
575 elif node.value == "EMPTY":
576 return Timespan.makeEmpty()
577 else:
578 d = loader.construct_mapping(node)
579 return Timespan(d["begin"], d["end"])
582# Register Timespan -> YAML conversion method with Dumper class
583yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
585# Register YAML -> Timespan conversion method with Loader, for our use case we
586# only need SafeLoader.
587yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)