Coverage for python/lsst/daf/butler/_timespan.py: 28%
197 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 10:07 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 10:07 +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__ = (
30 "SerializedTimespan",
31 "Timespan",
32)
34import enum
35import warnings
36from collections.abc import Generator
37from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Union
39import astropy.time
40import astropy.utils.exceptions
41import yaml
42from pydantic import Field
44# As of astropy 4.2, the erfa interface is shipped independently and
45# ErfaWarning is no longer an AstropyWarning
46try:
47 import erfa
48except ImportError:
49 erfa = None
51from lsst.utils.classes import cached_getter
53from .json import from_json_generic, to_json_generic
54from .time_utils import TimeConverter
56if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
57 from .dimensions import DimensionUniverse
58 from .registry import Registry
61_ONE_DAY = astropy.time.TimeDelta("1d", scale="tai")
64class _SpecialTimespanBound(enum.Enum):
65 """Enumeration to provide a singleton value for empty timespan bounds.
67 This enum's only member should generally be accessed via the
68 `Timespan.EMPTY` alias.
69 """
71 EMPTY = enum.auto()
72 """The value used for both `Timespan.begin` and `Timespan.end` for empty
73 Timespans that contain no points.
74 """
77TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
79SerializedTimespan = Annotated[list[int], Field(min_length=2, max_length=2)]
80"""JSON-serializable representation of the Timespan class, as a list of two
81integers ``[begin, end]`` in nanoseconds since the epoch.
82"""
85class Timespan:
86 """A half-open time interval with nanosecond precision.
88 Parameters
89 ----------
90 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
91 Minimum timestamp in the interval (inclusive). `None` indicates that
92 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
93 timespan contains no times; if this is used as either bound, the other
94 bound is ignored.
95 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
96 Maximum timestamp in the interval (exclusive). `None` indicates that
97 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
98 creates an empty timespan.
99 padInstantaneous : `bool`, optional
100 If `True` (default) and ``begin == end`` *after discretization to
101 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
102 finite-duration timespan. If `False`, ``begin == end`` evaluates to
103 the empty timespan.
104 _nsec : `tuple` of `int`, optional
105 Integer nanosecond representation, for internal use by `Timespan` and
106 `TimespanDatabaseRepresentation` implementation only. If provided,
107 all other arguments are are ignored.
109 Raises
110 ------
111 TypeError
112 Raised if ``begin`` or ``end`` has a type other than
113 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
114 ValueError
115 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
116 supported by this class.
118 Notes
119 -----
120 Timespans are half-open intervals, i.e. ``[begin, end)``.
122 Any timespan with ``begin > end`` after nanosecond discretization
123 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
124 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
125 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
126 contained by all other timespans (including itself). It is also disjoint
127 with all timespans (including itself), and hence does not overlap any
128 timespan - this is the only case where ``contains`` does not imply
129 ``overlaps``.
131 Finite timespan bounds are represented internally as integer nanoseconds,
132 and hence construction from `astropy.time.Time` (which has picosecond
133 accuracy) can involve a loss of precision. This is of course
134 deterministic, so any `astropy.time.Time` value is always mapped
135 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
136 timespans that are empty at full precision (``begin > end``,
137 ``begin - end < 1ns``) may be finite after discretization. In all other
138 cases, the relationships between full-precision timespans should be
139 preserved even if the values are not.
141 The `astropy.time.Time` bounds that can be obtained after construction from
142 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
143 exactly when used to construct other `Timespan` instances.
144 """
146 def __init__(
147 self,
148 begin: TimespanBound,
149 end: TimespanBound,
150 padInstantaneous: bool = True,
151 _nsec: tuple[int, int] | None = None,
152 ):
153 converter = TimeConverter()
154 if _nsec is None:
155 begin_nsec: int
156 if begin is None:
157 begin_nsec = converter.min_nsec
158 elif begin is self.EMPTY:
159 begin_nsec = converter.max_nsec
160 elif isinstance(begin, astropy.time.Time):
161 begin_nsec = converter.astropy_to_nsec(begin)
162 else:
163 raise TypeError(
164 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
165 )
166 end_nsec: int
167 if end is None:
168 end_nsec = converter.max_nsec
169 elif end is self.EMPTY:
170 end_nsec = converter.min_nsec
171 elif isinstance(end, astropy.time.Time):
172 end_nsec = converter.astropy_to_nsec(end)
173 else:
174 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.")
175 if begin_nsec == end_nsec:
176 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
177 with warnings.catch_warnings():
178 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
179 if erfa is not None:
180 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
181 if begin is not None and begin < converter.epoch:
182 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
183 if end is not None and end > converter.max_time:
184 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
185 raise ValueError("Infinite instantaneous timespans are not supported.")
186 elif padInstantaneous:
187 end_nsec += 1
188 if end_nsec == converter.max_nsec:
189 raise ValueError(
190 f"Cannot construct near-instantaneous timespan at {end}; "
191 "within one ns of maximum time."
192 )
193 _nsec = (begin_nsec, end_nsec)
194 if _nsec[0] >= _nsec[1]:
195 # Standardizing all empty timespans to the same underlying values
196 # here simplifies all other operations (including interactions
197 # with TimespanDatabaseRepresentation implementations).
198 _nsec = (converter.max_nsec, converter.min_nsec)
199 self._nsec = _nsec
201 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
203 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
205 # YAML tag name for Timespan
206 yaml_tag = "!lsst.daf.butler.Timespan"
208 @classmethod
209 def makeEmpty(cls) -> Timespan:
210 """Construct an empty timespan.
212 Returns
213 -------
214 empty : `Timespan`
215 A timespan that is contained by all timespans (including itself)
216 and overlaps no other timespans (including itself).
217 """
218 converter = TimeConverter()
219 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
221 @classmethod
222 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
223 """Construct a timespan that approximates an instant in time.
225 This is done by constructing a minimum-possible (1 ns) duration
226 timespan.
228 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
229 but may be slightly more efficient.
231 Parameters
232 ----------
233 time : `astropy.time.Time`
234 Time to use for the lower bound.
236 Returns
237 -------
238 instant : `Timespan`
239 A ``[time, time + 1ns)`` timespan.
240 """
241 converter = TimeConverter()
242 nsec = converter.astropy_to_nsec(time)
243 if nsec == converter.max_nsec - 1:
244 raise ValueError(
245 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time."
246 )
247 return Timespan(None, None, _nsec=(nsec, nsec + 1))
249 @classmethod
250 def from_day_obs(cls, day_obs: int, offset: int = 0) -> Timespan:
251 """Construct a timespan for a 24-hour period based on the day of
252 observation.
254 Parameters
255 ----------
256 day_obs : `int`
257 The day of observation as an integer of the form YYYYMMDD.
258 The year must be at least 1970 since these are converted to TAI.
259 offset : `int`, optional
260 Offset in seconds from TAI midnight to be applied.
262 Returns
263 -------
264 day_span : `Timespan`
265 A timespan corresponding to a full day of observing.
267 Notes
268 -----
269 If the observing day is 20240229 and the offset is 12 hours the
270 resulting time span will be 2024-02-29T12:00 to 2024-03-01T12:00.
271 """
272 if day_obs < 1970_00_00 or day_obs > 1_0000_00_00:
273 raise ValueError(f"day_obs must be in form yyyyMMDD and be newer than 1970, not {day_obs}.")
275 ymd = str(day_obs)
276 t1 = astropy.time.Time(f"{ymd[0:4]}-{ymd[4:6]}-{ymd[6:8]}T00:00:00", format="isot", scale="tai")
278 if offset != 0:
279 t_delta = astropy.time.TimeDelta(offset, format="sec", scale="tai")
280 t1 += t_delta
282 t2 = t1 + _ONE_DAY
284 return Timespan(t1, t2)
286 @property
287 @cached_getter
288 def begin(self) -> TimespanBound:
289 """Minimum timestamp in the interval, inclusive.
291 If this bound is finite, this is an `astropy.time.Time` instance.
292 If the timespan is unbounded from below, this is `None`.
293 If the timespan is empty, this is the special value `Timespan.EMPTY`.
294 """
295 if self.isEmpty():
296 return self.EMPTY
297 elif self._nsec[0] == TimeConverter().min_nsec:
298 return None
299 else:
300 return TimeConverter().nsec_to_astropy(self._nsec[0])
302 @property
303 @cached_getter
304 def end(self) -> TimespanBound:
305 """Maximum timestamp in the interval, exclusive.
307 If this bound is finite, this is an `astropy.time.Time` instance.
308 If the timespan is unbounded from above, this is `None`.
309 If the timespan is empty, this is the special value `Timespan.EMPTY`.
310 """
311 if self.isEmpty():
312 return self.EMPTY
313 elif self._nsec[1] == TimeConverter().max_nsec:
314 return None
315 else:
316 return TimeConverter().nsec_to_astropy(self._nsec[1])
318 def isEmpty(self) -> bool:
319 """Test whether ``self`` is the empty timespan (`bool`)."""
320 return self._nsec[0] >= self._nsec[1]
322 def __str__(self) -> str:
323 if self.isEmpty():
324 return "(empty)"
325 fmt = "%Y-%m-%dT%H:%M:%S"
326 # Trap dubious year warnings in case we have timespans from
327 # simulated data in the future
328 with warnings.catch_warnings():
329 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
330 if erfa is not None:
331 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
332 if self.begin is None:
333 head = "(-∞, "
334 else:
335 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
336 head = f"[{self.begin.tai.strftime(fmt)}, "
337 if self.end is None:
338 tail = "∞)"
339 else:
340 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
341 tail = f"{self.end.tai.strftime(fmt)})"
342 return head + tail
344 def __repr__(self) -> str:
345 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
346 # simulate our own here to make Timespan's __repr__ eval-friendly.
347 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
348 # eval-friendly __repr__.
349 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
350 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
351 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
352 return f"Timespan(begin={begin}, end={end})"
354 def __eq__(self, other: Any) -> bool:
355 if not isinstance(other, Timespan):
356 return False
357 # Correctness of this simple implementation depends on __init__
358 # standardizing all empty timespans to a single value.
359 return self._nsec == other._nsec
361 def __hash__(self) -> int:
362 # Correctness of this simple implementation depends on __init__
363 # standardizing all empty timespans to a single value.
364 return hash(self._nsec)
366 def __reduce__(self) -> tuple:
367 return (Timespan, (None, None, False, self._nsec))
369 def __lt__(self, other: astropy.time.Time | Timespan) -> bool:
370 """Test if a Timespan's bounds are strictly less than the given time.
372 Parameters
373 ----------
374 other : `Timespan` or `astropy.time.Time`.
375 Timespan or instant in time to relate to ``self``.
377 Returns
378 -------
379 less : `bool`
380 The result of the less-than test. `False` if either operand is
381 empty.
382 """
383 # First term in each expression below is the "normal" one; the second
384 # ensures correct behavior for empty timespans. It's important that
385 # the second uses a strict inequality to make sure inf == inf isn't in
386 # play, and it's okay for the second to use a strict inequality only
387 # because we know non-empty Timespans have nonzero duration, and hence
388 # the second term is never false for non-empty timespans unless the
389 # first term is also false.
390 if isinstance(other, astropy.time.Time):
391 nsec = TimeConverter().astropy_to_nsec(other)
392 return self._nsec[1] <= nsec and self._nsec[0] < nsec
393 else:
394 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
396 def __gt__(self, other: astropy.time.Time | Timespan) -> bool:
397 """Test if a Timespan's bounds are strictly greater than given time.
399 Parameters
400 ----------
401 other : `Timespan` or `astropy.time.Time`.
402 Timespan or instant in time to relate to ``self``.
404 Returns
405 -------
406 greater : `bool`
407 The result of the greater-than test. `False` if either operand is
408 empty.
409 """
410 # First term in each expression below is the "normal" one; the second
411 # ensures correct behavior for empty timespans. It's important that
412 # the second uses a strict inequality to make sure inf == inf isn't in
413 # play, and it's okay for the second to use a strict inequality only
414 # because we know non-empty Timespans have nonzero duration, and hence
415 # the second term is never false for non-empty timespans unless the
416 # first term is also false.
417 if isinstance(other, astropy.time.Time):
418 nsec = TimeConverter().astropy_to_nsec(other)
419 return self._nsec[0] > nsec and self._nsec[1] > nsec
420 else:
421 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
423 def overlaps(self, other: Timespan | astropy.time.Time) -> bool:
424 """Test if the intersection of this Timespan with another is empty.
426 Parameters
427 ----------
428 other : `Timespan` or `astropy.time.Time`
429 Timespan or time to relate to ``self``. If a single time, this is
430 a synonym for `contains`.
432 Returns
433 -------
434 overlaps : `bool`
435 The result of the overlap test.
437 Notes
438 -----
439 If either ``self`` or ``other`` is empty, the result is always `False`.
440 In all other cases, ``self.contains(other)`` being `True` implies that
441 ``self.overlaps(other)`` is also `True`.
442 """
443 if isinstance(other, astropy.time.Time):
444 return self.contains(other)
445 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
447 def contains(self, other: astropy.time.Time | Timespan) -> bool:
448 """Test if the supplied timespan is within this one.
450 Tests whether the intersection of this timespan with another timespan
451 or point is equal to the other one.
453 Parameters
454 ----------
455 other : `Timespan` or `astropy.time.Time`
456 Timespan or instant in time to relate to ``self``.
458 Returns
459 -------
460 overlaps : `bool`
461 The result of the contains test.
463 Notes
464 -----
465 If ``other`` is empty, `True` is always returned. In all other cases,
466 ``self.contains(other)`` being `True` implies that
467 ``self.overlaps(other)`` is also `True`.
469 Testing whether an instantaneous `astropy.time.Time` value is contained
470 in a timespan is not equivalent to testing a timespan constructed via
471 `Timespan.fromInstant`, because Timespan cannot exactly represent
472 zero-duration intervals. In particular, ``[a, b)`` contains the time
473 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
474 by `Timespan.fromInstant(b)``.
475 """
476 if isinstance(other, astropy.time.Time):
477 nsec = TimeConverter().astropy_to_nsec(other)
478 return self._nsec[0] <= nsec and self._nsec[1] > nsec
479 else:
480 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
482 def intersection(self, *args: Timespan) -> Timespan:
483 """Return a new `Timespan` that is contained by all of the given ones.
485 Parameters
486 ----------
487 *args
488 All positional arguments are `Timespan` instances.
490 Returns
491 -------
492 intersection : `Timespan`
493 The intersection timespan.
494 """
495 if not args:
496 return self
497 lowers = [self._nsec[0]]
498 lowers.extend(ts._nsec[0] for ts in args)
499 uppers = [self._nsec[1]]
500 uppers.extend(ts._nsec[1] for ts in args)
501 nsec = (max(*lowers), min(*uppers))
502 return Timespan(begin=None, end=None, _nsec=nsec)
504 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
505 """Return the one or two timespans that cover the interval(s).
507 The interval is defined as one that is in ``self`` but not ``other``.
509 This is implemented as a generator because the result may be zero, one,
510 or two `Timespan` objects, depending on the relationship between the
511 operands.
513 Parameters
514 ----------
515 other : `Timespan`
516 Timespan to subtract.
518 Yields
519 ------
520 result : `Timespan`
521 A `Timespan` that is contained by ``self`` but does not overlap
522 ``other``. Guaranteed not to be empty.
523 """
524 intersection = self.intersection(other)
525 if intersection.isEmpty():
526 yield self
527 elif intersection == self:
528 yield from ()
529 else:
530 if intersection._nsec[0] > self._nsec[0]:
531 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
532 if intersection._nsec[1] < self._nsec[1]:
533 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
535 def to_simple(self, minimal: bool = False) -> SerializedTimespan:
536 """Return simple python type form suitable for serialization.
538 Parameters
539 ----------
540 minimal : `bool`, optional
541 Use minimal serialization. Has no effect on for this class.
543 Returns
544 -------
545 simple : `list` of `int`
546 The internal span as integer nanoseconds.
547 """
548 # Return the internal nanosecond form rather than astropy ISO string
549 return list(self._nsec)
551 @classmethod
552 def from_simple(
553 cls,
554 simple: SerializedTimespan | None,
555 universe: DimensionUniverse | None = None,
556 registry: Registry | None = None,
557 ) -> Timespan | None:
558 """Construct a new object from simplified form.
560 Designed to use the data returned from the `to_simple` method.
562 Parameters
563 ----------
564 simple : `list` of `int`, or `None`
565 The values returned by `to_simple()`.
566 universe : `DimensionUniverse`, optional
567 Unused.
568 registry : `lsst.daf.butler.Registry`, optional
569 Unused.
571 Returns
572 -------
573 result : `Timespan` or `None`
574 Newly-constructed object.
575 """
576 if simple is None:
577 return None
578 nsec1, nsec2 = simple # for mypy
579 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
581 to_json = to_json_generic
582 from_json: ClassVar = classmethod(from_json_generic)
584 @classmethod
585 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
586 """Convert Timespan into YAML format.
588 This produces a scalar node with a tag "!_SpecialTimespanBound" and
589 value being a name of _SpecialTimespanBound enum.
591 Parameters
592 ----------
593 dumper : `yaml.Dumper`
594 YAML dumper instance.
595 timespan : `Timespan`
596 Data to be converted.
597 """
598 if timespan.isEmpty():
599 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
600 else:
601 return dumper.represent_mapping(
602 cls.yaml_tag,
603 dict(begin=timespan.begin, end=timespan.end),
604 )
606 @classmethod
607 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None:
608 """Convert YAML node into _SpecialTimespanBound.
610 Parameters
611 ----------
612 loader : `yaml.SafeLoader`
613 Instance of YAML loader class.
614 node : `yaml.ScalarNode`
615 YAML node.
617 Returns
618 -------
619 value : `Timespan`
620 Timespan instance, can be ``None``.
621 """
622 if node.value is None:
623 return None
624 elif node.value == "EMPTY":
625 return Timespan.makeEmpty()
626 else:
627 d = loader.construct_mapping(node)
628 return Timespan(d["begin"], d["end"])
631# Register Timespan -> YAML conversion method with Dumper class
632yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
634# Register YAML -> Timespan conversion method with Loader, for our use case we
635# only need SafeLoader.
636yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)