Coverage for python/lsst/daf/butler/core/timespan.py : 26%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = (
24 "Timespan",
25 "TimespanDatabaseRepresentation",
26)
28from abc import abstractmethod
29import enum
30from typing import (
31 TYPE_CHECKING,
32 Any,
33 ClassVar,
34 Dict,
35 Generator,
36 Iterator,
37 List,
38 Mapping,
39 Optional,
40 Tuple,
41 Type,
42 TypeVar,
43 Union,
44)
46import astropy.time
47import astropy.utils.exceptions
48import sqlalchemy
49import warnings
51# As of astropy 4.2, the erfa interface is shipped independently and
52# ErfaWarning is no longer an AstropyWarning
53try:
54 import erfa
55except ImportError:
56 erfa = None
58from . import ddl
59from .time_utils import TimeConverter
60from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace
61from .utils import cached_getter
62from .json import from_json_generic, to_json_generic
64if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true
65 from .dimensions import DimensionUniverse
66 from ..registry import Registry
69class _SpecialTimespanBound(enum.Enum):
70 """Enumeration to provide a singleton value for empty timespan bounds.
72 This enum's only member should generally be accessed via the
73 `Timespan.EMPTY` alias.
74 """
76 EMPTY = enum.auto()
77 """The value used for both `Timespan.begin` and `Timespan.end` for empty
78 Timespans that contain no points.
79 """
82TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
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 """
145 def __init__(self, begin: TimespanBound, end: TimespanBound, padInstantaneous: bool = True,
146 _nsec: Optional[Tuple[int, int]] = None):
147 converter = TimeConverter()
148 if _nsec is None:
149 begin_nsec: int
150 if begin is None:
151 begin_nsec = converter.min_nsec
152 elif begin is self.EMPTY:
153 begin_nsec = converter.max_nsec
154 elif isinstance(begin, astropy.time.Time):
155 begin_nsec = converter.astropy_to_nsec(begin)
156 else:
157 raise TypeError(
158 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
159 )
160 end_nsec: int
161 if end is None:
162 end_nsec = converter.max_nsec
163 elif end is self.EMPTY:
164 end_nsec = converter.min_nsec
165 elif isinstance(end, astropy.time.Time):
166 end_nsec = converter.astropy_to_nsec(end)
167 else:
168 raise TypeError(
169 f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}."
170 )
171 if begin_nsec == end_nsec:
172 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
173 with warnings.catch_warnings():
174 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
175 if erfa is not None:
176 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
177 if begin is not None and begin < converter.epoch:
178 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
179 if end is not None and end > converter.max_time:
180 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
181 raise ValueError("Infinite instantaneous timespans are not supported.")
182 elif padInstantaneous:
183 end_nsec += 1
184 if end_nsec == converter.max_nsec:
185 raise ValueError(
186 f"Cannot construct near-instantaneous timespan at {end}; "
187 "within one ns of maximum time."
188 )
189 _nsec = (begin_nsec, end_nsec)
190 if _nsec[0] >= _nsec[1]:
191 # Standardizing all empty timespans to the same underlying values
192 # here simplifies all other operations (including interactions
193 # with TimespanDatabaseRepresentation implementations).
194 _nsec = (converter.max_nsec, converter.min_nsec)
195 self._nsec = _nsec
197 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
199 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
201 @classmethod
202 def makeEmpty(cls) -> Timespan:
203 """Construct an empty timespan.
205 Returns
206 -------
207 empty : `Timespan`
208 A timespan that is contained by all timespans (including itself)
209 and overlaps no other timespans (including itself).
210 """
211 converter = TimeConverter()
212 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
214 @classmethod
215 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
216 """Construct a timespan that approximates an instant in time by a
217 minimum-possible (1 ns) duration timespan.
219 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
220 but may be slightly more efficient.
222 Parameters
223 ----------
224 time : `astropy.time.Time`
225 Time to use for the lower bound.
227 Returns
228 -------
229 instant : `Timespan`
230 A ``[time, time + 1ns)`` timespan.
231 """
232 converter = TimeConverter()
233 nsec = converter.astropy_to_nsec(time)
234 if nsec == converter.max_nsec - 1:
235 raise ValueError(
236 f"Cannot construct near-instantaneous timespan at {time}; "
237 "within one ns of maximum time."
238 )
239 return Timespan(None, None, _nsec=(nsec, nsec + 1))
241 @property # type: ignore
242 @cached_getter
243 def begin(self) -> TimespanBound:
244 """Minimum timestamp in the interval, inclusive.
246 If this bound is finite, this is an `astropy.time.Time` instance.
247 If the timespan is unbounded from below, this is `None`.
248 If the timespan is empty, this is the special value `Timespan.EMPTY`.
249 """
250 if self.isEmpty():
251 return self.EMPTY
252 elif self._nsec[0] == TimeConverter().min_nsec:
253 return None
254 else:
255 return TimeConverter().nsec_to_astropy(self._nsec[0])
257 @property # type: ignore
258 @cached_getter
259 def end(self) -> TimespanBound:
260 """Maximum timestamp in the interval, exclusive.
262 If this bound is finite, this is an `astropy.time.Time` instance.
263 If the timespan is unbounded from above, this is `None`.
264 If the timespan is empty, this is the special value `Timespan.EMPTY`.
265 """
266 if self.isEmpty():
267 return self.EMPTY
268 elif self._nsec[1] == TimeConverter().max_nsec:
269 return None
270 else:
271 return TimeConverter().nsec_to_astropy(self._nsec[1])
273 def isEmpty(self) -> bool:
274 """Test whether ``self`` is the empty timespan (`bool`).
275 """
276 return self._nsec[0] >= self._nsec[1]
278 def __str__(self) -> str:
279 if self.isEmpty():
280 return "(empty)"
281 # Trap dubious year warnings in case we have timespans from
282 # simulated data in the future
283 with warnings.catch_warnings():
284 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
285 if erfa is not None:
286 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
287 if self.begin is None:
288 head = "(-∞, "
289 else:
290 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
291 head = f"[{self.begin.tai.isot}, "
292 if self.end is None:
293 tail = "∞)"
294 else:
295 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
296 tail = f"{self.end.tai.isot})"
297 return head + tail
299 def __repr__(self) -> str:
300 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
301 # simulate our own here to make Timespan's __repr__ eval-friendly.
302 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
303 # eval-friendly __repr__.
304 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
305 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
306 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
307 return f"Timespan(begin={begin}, end={end})"
309 def __eq__(self, other: Any) -> bool:
310 if not isinstance(other, Timespan):
311 return False
312 # Correctness of this simple implementation depends on __init__
313 # standardizing all empty timespans to a single value.
314 return self._nsec == other._nsec
316 def __hash__(self) -> int:
317 # Correctness of this simple implementation depends on __init__
318 # standardizing all empty timespans to a single value.
319 return hash(self._nsec)
321 def __reduce__(self) -> tuple:
322 return (Timespan, (None, None, False, self._nsec))
324 def __lt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
325 """Test whether a Timespan's bounds are strictly less than the given
326 time or timespan.
329 Parameters
330 ----------
331 other : `Timespan` or `astropy.time.Time`.
332 Timespan or instant in time to relate to ``self``.
334 Returns
335 -------
336 less : `bool`
337 The result of the less-than test. `False` if either operand is
338 empty.
339 """
340 # First term in each expression below is the "normal" one; the second
341 # ensures correct behavior for empty timespans. It's important that
342 # the second uses a strict inequality to make sure inf == inf isn't in
343 # play, and it's okay for the second to use a strict inequality only
344 # because we know non-empty Timespans have nonzero duration, and hence
345 # the second term is never false for non-empty timespans unless the
346 # first term is also false.
347 if isinstance(other, astropy.time.Time):
348 nsec = TimeConverter().astropy_to_nsec(other)
349 return self._nsec[1] <= nsec and self._nsec[0] < nsec
350 else:
351 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
353 def __gt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
354 """Test whether a Timespan's bounds are strictly greater than the given
355 time or timespan.
358 Parameters
359 ----------
360 other : `Timespan` or `astropy.time.Time`.
361 Timespan or instant in time to relate to ``self``.
363 Returns
364 -------
365 greater : `bool`
366 The result of the greater-than test. `False` if either operand is
367 empty.
368 """
369 # First term in each expression below is the "normal" one; the second
370 # ensures correct behavior for empty timespans. It's important that
371 # the second uses a strict inequality to make sure inf == inf isn't in
372 # play, and it's okay for the second to use a strict inequality only
373 # because we know non-empty Timespans have nonzero duration, and hence
374 # the second term is never false for non-empty timespans unless the
375 # first term is also false.
376 if isinstance(other, astropy.time.Time):
377 nsec = TimeConverter().astropy_to_nsec(other)
378 return self._nsec[0] > nsec and self._nsec[1] > nsec
379 else:
380 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
382 def overlaps(self, other: Timespan) -> bool:
383 """Test whether the intersection of this Timespan with another
384 is empty.
386 Parameters
387 ----------
388 other : `Timespan`
389 Timespan to relate to ``self``.
391 Returns
392 -------
393 overlaps : `bool`
394 The result of the overlap test.
396 Notes
397 -----
398 If either ``self`` or ``other`` is empty, the result is always `False`.
399 In all other cases, ``self.contains(other)`` being `True` implies that
400 ``self.overlaps(other)`` is also `True`.
401 """
402 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
404 def contains(self, other: Union[astropy.time.Time, Timespan]) -> bool:
405 """Test whether the intersection of this timespan with another timespan
406 or point is equal to the other one.
408 Parameters
409 ----------
410 other : `Timespan` or `astropy.time.Time`.
411 Timespan or instant in time to relate to ``self``.
413 Returns
414 -------
415 overlaps : `bool`
416 The result of the contains test.
418 Notes
419 -----
420 If ``other`` is empty, `True` is always returned. In all other cases,
421 ``self.contains(other)`` being `True` implies that
422 ``self.overlaps(other)`` is also `True`.
424 Testing whether an instantaneous `astropy.time.Time` value is contained
425 in a timespan is not equivalent to testing a timespan constructed via
426 `Timespan.fromInstant`, because Timespan cannot exactly represent
427 zero-duration intervals. In particular, ``[a, b)`` contains the time
428 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
429 by `Timespan.fromInstant(b)``.
430 """
431 if isinstance(other, astropy.time.Time):
432 nsec = TimeConverter().astropy_to_nsec(other)
433 return self._nsec[0] <= nsec and self._nsec[1] > nsec
434 else:
435 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
437 def intersection(self, *args: Timespan) -> Timespan:
438 """Return a new `Timespan` that is contained by all of the given ones.
440 Parameters
441 ----------
442 *args
443 All positional arguments are `Timespan` instances.
445 Returns
446 -------
447 intersection : `Timespan`
448 The intersection timespan.
449 """
450 if not args:
451 return self
452 lowers = [self._nsec[0]]
453 lowers.extend(ts._nsec[0] for ts in args)
454 uppers = [self._nsec[1]]
455 uppers.extend(ts._nsec[1] for ts in args)
456 nsec = (max(*lowers), min(*uppers))
457 return Timespan(begin=None, end=None, _nsec=nsec)
459 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
460 """Return the one or two timespans that cover the interval(s) that are
461 in ``self`` but not ``other``.
463 This is implemented as a generator because the result may be zero, one,
464 or two `Timespan` objects, depending on the relationship between the
465 operands.
467 Parameters
468 ----------
469 other : `Timespan`
470 Timespan to subtract.
472 Yields
473 ------
474 result : `Timespan`
475 A `Timespan` that is contained by ``self`` but does not overlap
476 ``other``. Guaranteed not to be empty.
477 """
478 intersection = self.intersection(other)
479 if intersection.isEmpty():
480 yield self
481 elif intersection == self:
482 yield from ()
483 else:
484 if intersection._nsec[0] > self._nsec[0]:
485 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
486 if intersection._nsec[1] < self._nsec[1]:
487 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
489 def to_simple(self, minimal: bool = False) -> List[int]:
490 """Convert this class to a simple python type suitable for
491 serialization.
493 Parameters
494 ----------
495 minimal : `bool`, optional
496 Use minimal serialization. Has no effect on for this class.
498 Returns
499 -------
500 simple : `list` of `int`
501 The internal span as integer nanoseconds.
502 """
503 # Return the internal nanosecond form rather than astropy ISO string
504 return list(self._nsec)
506 @classmethod
507 def from_simple(cls, simple: List[int],
508 universe: Optional[DimensionUniverse] = None,
509 registry: Optional[Registry] = None) -> Timespan:
510 """Construct a new object from the data returned from the `to_simple`
511 method.
513 Parameters
514 ----------
515 simple : `list` of `int`
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`
525 Newly-constructed object.
526 """
527 nsec1, nsec2 = simple # for mypy
528 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
530 to_json = to_json_generic
531 from_json = classmethod(from_json_generic)
534_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
537class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]):
538 """An interface that encapsulates how timespans are represented in a
539 database engine.
541 Most of this class's interface is comprised of classmethods. Instances
542 can be constructed via the `fromSelectable` or `fromLiteral` methods as a
543 way to include timespan overlap operations in query JOIN or WHERE clauses.
545 Notes
546 -----
547 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
548 same interval definitions and edge-case behavior as the `Timespan` class.
549 They are also guaranteed to round-trip `Timespan` instances exactly.
550 """
552 NAME: ClassVar[str] = "timespan"
553 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL
555 Compound: ClassVar[Type[TimespanDatabaseRepresentation]]
556 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
557 uses two separate fields for the begin (inclusive) and end (excusive)
558 endpoints.
560 This implementation should be compatible with any SQL database, and should
561 generally be used when a database-specific implementation is not available.
562 """
564 __slots__ = ()
566 @classmethod
567 @abstractmethod
568 def fromLiteral(cls: Type[_S], timespan: Timespan) -> _S:
569 """Construct a database timespan representation from a literal
570 `Timespan` instance.
572 Parameters
573 ----------
574 timespan : `Timespan`
575 Literal timespan to convert.
577 Returns
578 -------
579 tsRepr : `TimespanDatabaseRepresentation`
580 A timespan expression object backed by `sqlalchemy.sql.literal`
581 column expressions.
582 """
583 raise NotImplementedError()
585 @abstractmethod
586 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
587 """Return a boolean SQLAlchemy expression that tests whether the
588 timespan is empty.
590 Returns
591 -------
592 empty : `sqlalchemy.sql.ColumnElement`
593 A boolean SQLAlchemy expression object.
594 """
595 raise NotImplementedError()
597 @abstractmethod
598 def __lt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
599 """Return a SQLAlchemy expression representing a test for whether an
600 in-database timespan is strictly less than another timespan or a time
601 point.
603 Parameters
604 ----------
605 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
606 The timespan or time to relate to ``self``; either an instance of
607 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
608 a SQL column expression representing an `astropy.time.Time`.
610 Returns
611 -------
612 less : `sqlalchemy.sql.ColumnElement`
613 A boolean SQLAlchemy expression object.
615 Notes
616 -----
617 See `Timespan.__lt__` for edge-case behavior.
618 """
619 raise NotImplementedError()
621 @abstractmethod
622 def __gt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
623 """Return a SQLAlchemy expression representing a test for whether an
624 in-database timespan is strictly greater than another timespan or a
625 time point.
627 Parameters
628 ----------
629 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
630 The timespan or time to relate to ``self``; either an instance of
631 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
632 a SQL column expression representing an `astropy.time.Time`.
634 Returns
635 -------
636 greater : `sqlalchemy.sql.ColumnElement`
637 A boolean SQLAlchemy expression object.
639 Notes
640 -----
641 See `Timespan.__gt__` for edge-case behavior.
642 """
643 raise NotImplementedError()
645 @abstractmethod
646 def overlaps(self: _S, other: _S) -> sqlalchemy.sql.ColumnElement:
647 """Return a SQLAlchemy expression representing an overlap operation on
648 timespans.
650 Parameters
651 ----------
652 other : ``type(self)``
653 The timespan to overlap ``self`` with.
655 Returns
656 -------
657 overlap : `sqlalchemy.sql.ColumnElement`
658 A boolean SQLAlchemy expression object.
660 Notes
661 -----
662 See `Timespan.overlaps` for edge-case behavior.
663 """
664 raise NotImplementedError()
666 @abstractmethod
667 def contains(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
668 """Return a SQLAlchemy expression representing a test for whether an
669 in-database timespan contains another timespan or a time point.
671 Parameters
672 ----------
673 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
674 The timespan or time to relate to ``self``; either an instance of
675 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
676 a SQL column expression representing an `astropy.time.Time`.
678 Returns
679 -------
680 contains : `sqlalchemy.sql.ColumnElement`
681 A boolean SQLAlchemy expression object.
683 Notes
684 -----
685 See `Timespan.contains` for edge-case behavior.
686 """
687 raise NotImplementedError()
690class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
691 """An implementation of `TimespanDatabaseRepresentation` that simply stores
692 the endpoints in two separate fields.
694 This type should generally be accessed via
695 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
696 via the `fromSelectable` and `fromLiteral` methods.
698 Parameters
699 ----------
700 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
701 Tuple of SQLAlchemy objects representing the lower (inclusive) and
702 upper (exclusive) bounds, as 64-bit integer columns containing
703 nanoseconds.
704 name : `str`, optional
705 Name for the logical column; a part of the name for multi-column
706 representations. Defaults to ``cls.NAME``.
708 Notes
709 -----
710 ``NULL`` timespans are represented by having both fields set to ``NULL``;
711 setting only one to ``NULL`` is considered a corrupted state that should
712 only be possible if this interface is circumvented. `Timespan` instances
713 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
714 are set to fields mapped to the minimum and maximum value constants used
715 by our integer-time mapping.
716 """
717 def __init__(self, nsec: Tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
718 self._nsec = nsec
719 self._name = name
721 __slots__ = ("_nsec", "_name")
723 @classmethod
724 def makeFieldSpecs(cls, nullable: bool, name: Optional[str] = None, **kwargs: Any
725 ) -> Tuple[ddl.FieldSpec, ...]:
726 # Docstring inherited.
727 if name is None:
728 name = cls.NAME
729 return (
730 ddl.FieldSpec(
731 f"{name}_begin", dtype=sqlalchemy.BigInteger, nullable=nullable,
732 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
733 **kwargs,
734 ),
735 ddl.FieldSpec(
736 f"{name}_end", dtype=sqlalchemy.BigInteger, nullable=nullable,
737 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
738 **kwargs,
739 ),
740 )
742 @classmethod
743 def getFieldNames(cls, name: Optional[str] = None) -> Tuple[str, ...]:
744 # Docstring inherited.
745 if name is None:
746 name = cls.NAME
747 return (f"{name}_begin", f"{name}_end")
749 @classmethod
750 def update(cls, extent: Optional[Timespan], name: Optional[str] = None,
751 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
752 # Docstring inherited.
753 if name is None:
754 name = cls.NAME
755 if result is None:
756 result = {}
757 if extent is None:
758 begin_nsec = None
759 end_nsec = None
760 else:
761 begin_nsec = extent._nsec[0]
762 end_nsec = extent._nsec[1]
763 result[f"{name}_begin"] = begin_nsec
764 result[f"{name}_end"] = end_nsec
765 return result
767 @classmethod
768 def extract(cls, mapping: Mapping[str, Any], name: Optional[str] = None) -> Optional[Timespan]:
769 # Docstring inherited.
770 if name is None:
771 name = cls.NAME
772 begin_nsec = mapping[f"{name}_begin"]
773 end_nsec = mapping[f"{name}_end"]
774 if begin_nsec is None:
775 if end_nsec is not None:
776 raise RuntimeError(
777 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
778 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
779 )
780 return None
781 elif end_nsec is None:
782 raise RuntimeError(
783 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
784 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
785 )
786 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
788 @classmethod
789 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause,
790 name: Optional[str] = None) -> _CompoundTimespanDatabaseRepresentation:
791 # Docstring inherited.
792 if name is None:
793 name = cls.NAME
794 return cls(nsec=(selectable.columns[f"{name}_begin"], selectable.columns[f"{name}_end"]),
795 name=name)
797 @classmethod
798 def fromLiteral(cls, timespan: Timespan) -> _CompoundTimespanDatabaseRepresentation:
799 # Docstring inherited.
800 return cls(
801 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
802 name=cls.NAME,
803 )
805 @property
806 def name(self) -> str:
807 # Docstring inherited.
808 return self._name
810 def isNull(self) -> sqlalchemy.sql.ColumnElement:
811 # Docstring inherited.
812 return self._nsec[0].is_(None)
814 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
815 # Docstring inherited.
816 return self._nsec[0] >= self._nsec[1]
818 def __lt__(
819 self,
820 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
821 ) -> sqlalchemy.sql.ColumnElement:
822 # Docstring inherited.
823 # See comments in Timespan.__lt__ for why we use these exact
824 # expressions.
825 if isinstance(other, sqlalchemy.sql.ColumnElement):
826 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
827 else:
828 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
830 def __gt__(
831 self,
832 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
833 ) -> sqlalchemy.sql.ColumnElement:
834 # Docstring inherited.
835 # See comments in Timespan.__gt__ for why we use these exact
836 # expressions.
837 if isinstance(other, sqlalchemy.sql.ColumnElement):
838 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
839 else:
840 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
842 def overlaps(self, other: _CompoundTimespanDatabaseRepresentation) -> sqlalchemy.sql.ColumnElement:
843 # Docstring inherited.
844 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
846 def contains(
847 self,
848 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
849 ) -> sqlalchemy.sql.ColumnElement:
850 # Docstring inherited.
851 if isinstance(other, sqlalchemy.sql.ColumnElement):
852 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
853 else:
854 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
856 def flatten(self, name: Optional[str] = None) -> Iterator[sqlalchemy.sql.ColumnElement]:
857 # Docstring inherited.
858 if name is None:
859 yield from self._nsec
860 else:
861 yield self._nsec[0].label(f"{name}_begin")
862 yield self._nsec[1].label(f"{name}_end")
865TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation