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 """
146 def __init__(self, begin: TimespanBound, end: TimespanBound, padInstantaneous: bool = True,
147 _nsec: Optional[Tuple[int, int]] = None):
148 converter = TimeConverter()
149 if _nsec is None:
150 begin_nsec: int
151 if begin is None:
152 begin_nsec = converter.min_nsec
153 elif begin is self.EMPTY:
154 begin_nsec = converter.max_nsec
155 elif isinstance(begin, astropy.time.Time):
156 begin_nsec = converter.astropy_to_nsec(begin)
157 else:
158 raise TypeError(
159 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
160 )
161 end_nsec: int
162 if end is None:
163 end_nsec = converter.max_nsec
164 elif end is self.EMPTY:
165 end_nsec = converter.min_nsec
166 elif isinstance(end, astropy.time.Time):
167 end_nsec = converter.astropy_to_nsec(end)
168 else:
169 raise TypeError(
170 f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}."
171 )
172 if begin_nsec == end_nsec:
173 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
174 with warnings.catch_warnings():
175 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
176 if erfa is not None:
177 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
178 if begin is not None and begin < converter.epoch:
179 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
180 if end is not None and end > converter.max_time:
181 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
182 raise ValueError("Infinite instantaneous timespans are not supported.")
183 elif padInstantaneous:
184 end_nsec += 1
185 if end_nsec == converter.max_nsec:
186 raise ValueError(
187 f"Cannot construct near-instantaneous timespan at {end}; "
188 "within one ns of maximum time."
189 )
190 _nsec = (begin_nsec, end_nsec)
191 if _nsec[0] >= _nsec[1]:
192 # Standardizing all empty timespans to the same underlying values
193 # here simplifies all other operations (including interactions
194 # with TimespanDatabaseRepresentation implementations).
195 _nsec = (converter.max_nsec, converter.min_nsec)
196 self._nsec = _nsec
198 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
200 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
202 @classmethod
203 def makeEmpty(cls) -> Timespan:
204 """Construct an empty timespan.
206 Returns
207 -------
208 empty : `Timespan`
209 A timespan that is contained by all timespans (including itself)
210 and overlaps no other timespans (including itself).
211 """
212 converter = TimeConverter()
213 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
215 @classmethod
216 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
217 """Construct a timespan that approximates an instant in time.
219 This is done by constructing a minimum-possible (1 ns) duration
220 timespan.
222 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
223 but may be slightly more efficient.
225 Parameters
226 ----------
227 time : `astropy.time.Time`
228 Time to use for the lower bound.
230 Returns
231 -------
232 instant : `Timespan`
233 A ``[time, time + 1ns)`` timespan.
234 """
235 converter = TimeConverter()
236 nsec = converter.astropy_to_nsec(time)
237 if nsec == converter.max_nsec - 1:
238 raise ValueError(
239 f"Cannot construct near-instantaneous timespan at {time}; "
240 "within one ns of maximum time."
241 )
242 return Timespan(None, None, _nsec=(nsec, nsec + 1))
244 @property # type: ignore
245 @cached_getter
246 def begin(self) -> TimespanBound:
247 """Minimum timestamp in the interval, inclusive.
249 If this bound is finite, this is an `astropy.time.Time` instance.
250 If the timespan is unbounded from below, this is `None`.
251 If the timespan is empty, this is the special value `Timespan.EMPTY`.
252 """
253 if self.isEmpty():
254 return self.EMPTY
255 elif self._nsec[0] == TimeConverter().min_nsec:
256 return None
257 else:
258 return TimeConverter().nsec_to_astropy(self._nsec[0])
260 @property # type: ignore
261 @cached_getter
262 def end(self) -> TimespanBound:
263 """Maximum timestamp in the interval, exclusive.
265 If this bound is finite, this is an `astropy.time.Time` instance.
266 If the timespan is unbounded from above, this is `None`.
267 If the timespan is empty, this is the special value `Timespan.EMPTY`.
268 """
269 if self.isEmpty():
270 return self.EMPTY
271 elif self._nsec[1] == TimeConverter().max_nsec:
272 return None
273 else:
274 return TimeConverter().nsec_to_astropy(self._nsec[1])
276 def isEmpty(self) -> bool:
277 """Test whether ``self`` is the empty timespan (`bool`)."""
278 return self._nsec[0] >= self._nsec[1]
280 def __str__(self) -> str:
281 if self.isEmpty():
282 return "(empty)"
283 # Trap dubious year warnings in case we have timespans from
284 # simulated data in the future
285 with warnings.catch_warnings():
286 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
287 if erfa is not None:
288 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
289 if self.begin is None:
290 head = "(-∞, "
291 else:
292 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
293 head = f"[{self.begin.tai.isot}, "
294 if self.end is None:
295 tail = "∞)"
296 else:
297 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
298 tail = f"{self.end.tai.isot})"
299 return head + tail
301 def __repr__(self) -> str:
302 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
303 # simulate our own here to make Timespan's __repr__ eval-friendly.
304 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
305 # eval-friendly __repr__.
306 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
307 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
308 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
309 return f"Timespan(begin={begin}, end={end})"
311 def __eq__(self, other: Any) -> bool:
312 if not isinstance(other, Timespan):
313 return False
314 # Correctness of this simple implementation depends on __init__
315 # standardizing all empty timespans to a single value.
316 return self._nsec == other._nsec
318 def __hash__(self) -> int:
319 # Correctness of this simple implementation depends on __init__
320 # standardizing all empty timespans to a single value.
321 return hash(self._nsec)
323 def __reduce__(self) -> tuple:
324 return (Timespan, (None, None, False, self._nsec))
326 def __lt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
327 """Test if a Timespan's bounds are strictly less than the given time.
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 if a Timespan's bounds are strictly greater than given time.
356 Parameters
357 ----------
358 other : `Timespan` or `astropy.time.Time`.
359 Timespan or instant in time to relate to ``self``.
361 Returns
362 -------
363 greater : `bool`
364 The result of the greater-than test. `False` if either operand is
365 empty.
366 """
367 # First term in each expression below is the "normal" one; the second
368 # ensures correct behavior for empty timespans. It's important that
369 # the second uses a strict inequality to make sure inf == inf isn't in
370 # play, and it's okay for the second to use a strict inequality only
371 # because we know non-empty Timespans have nonzero duration, and hence
372 # the second term is never false for non-empty timespans unless the
373 # first term is also false.
374 if isinstance(other, astropy.time.Time):
375 nsec = TimeConverter().astropy_to_nsec(other)
376 return self._nsec[0] > nsec and self._nsec[1] > nsec
377 else:
378 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
380 def overlaps(self, other: Timespan) -> bool:
381 """Test if the intersection of this Timespan with another is empty.
383 Parameters
384 ----------
385 other : `Timespan`
386 Timespan to relate to ``self``.
388 Returns
389 -------
390 overlaps : `bool`
391 The result of the overlap test.
393 Notes
394 -----
395 If either ``self`` or ``other`` is empty, the result is always `False`.
396 In all other cases, ``self.contains(other)`` being `True` implies that
397 ``self.overlaps(other)`` is also `True`.
398 """
399 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
401 def contains(self, other: Union[astropy.time.Time, Timespan]) -> bool:
402 """Test if the supplied timespan is within this one.
404 Tests whether the intersection of this timespan with another timespan
405 or point is equal to the other one.
407 Parameters
408 ----------
409 other : `Timespan` or `astropy.time.Time`.
410 Timespan or instant in time to relate to ``self``.
412 Returns
413 -------
414 overlaps : `bool`
415 The result of the contains test.
417 Notes
418 -----
419 If ``other`` is empty, `True` is always returned. In all other cases,
420 ``self.contains(other)`` being `True` implies that
421 ``self.overlaps(other)`` is also `True`.
423 Testing whether an instantaneous `astropy.time.Time` value is contained
424 in a timespan is not equivalent to testing a timespan constructed via
425 `Timespan.fromInstant`, because Timespan cannot exactly represent
426 zero-duration intervals. In particular, ``[a, b)`` contains the time
427 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
428 by `Timespan.fromInstant(b)``.
429 """
430 if isinstance(other, astropy.time.Time):
431 nsec = TimeConverter().astropy_to_nsec(other)
432 return self._nsec[0] <= nsec and self._nsec[1] > nsec
433 else:
434 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
436 def intersection(self, *args: Timespan) -> Timespan:
437 """Return a new `Timespan` that is contained by all of the given ones.
439 Parameters
440 ----------
441 *args
442 All positional arguments are `Timespan` instances.
444 Returns
445 -------
446 intersection : `Timespan`
447 The intersection timespan.
448 """
449 if not args:
450 return self
451 lowers = [self._nsec[0]]
452 lowers.extend(ts._nsec[0] for ts in args)
453 uppers = [self._nsec[1]]
454 uppers.extend(ts._nsec[1] for ts in args)
455 nsec = (max(*lowers), min(*uppers))
456 return Timespan(begin=None, end=None, _nsec=nsec)
458 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
459 """Return the one or two timespans that cover the interval(s).
461 The interval is defined as one that is 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 """Return simple python type form suitable for serialization.
492 Parameters
493 ----------
494 minimal : `bool`, optional
495 Use minimal serialization. Has no effect on for this class.
497 Returns
498 -------
499 simple : `list` of `int`
500 The internal span as integer nanoseconds.
501 """
502 # Return the internal nanosecond form rather than astropy ISO string
503 return list(self._nsec)
505 @classmethod
506 def from_simple(cls, simple: List[int],
507 universe: Optional[DimensionUniverse] = None,
508 registry: Optional[Registry] = None) -> Timespan:
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`
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 """Representation of a time span within a database engine.
540 Provides an interface that encapsulates how timespans are represented in a
541 database engine.
543 Most of this class's interface is comprised of classmethods. Instances
544 can be constructed via the `fromSelectable` or `fromLiteral` methods as a
545 way to include timespan overlap operations in query JOIN or WHERE clauses.
547 Notes
548 -----
549 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
550 same interval definitions and edge-case behavior as the `Timespan` class.
551 They are also guaranteed to round-trip `Timespan` instances exactly.
552 """
554 NAME: ClassVar[str] = "timespan"
555 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL
557 Compound: ClassVar[Type[TimespanDatabaseRepresentation]]
558 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
559 uses two separate fields for the begin (inclusive) and end (excusive)
560 endpoints.
562 This implementation should be compatible with any SQL database, and should
563 generally be used when a database-specific implementation is not available.
564 """
566 __slots__ = ()
568 @classmethod
569 @abstractmethod
570 def fromLiteral(cls: Type[_S], timespan: Timespan) -> _S:
571 """Construct a database timespan from a literal `Timespan` instance.
573 Parameters
574 ----------
575 timespan : `Timespan`
576 Literal timespan to convert.
578 Returns
579 -------
580 tsRepr : `TimespanDatabaseRepresentation`
581 A timespan expression object backed by `sqlalchemy.sql.literal`
582 column expressions.
583 """
584 raise NotImplementedError()
586 @abstractmethod
587 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
588 """Return a boolean SQLAlchemy expression for testing empty timespans.
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 SQLAlchemy expression for testing less than.
601 Returns a SQLAlchemy expression representing a test for whether an
602 in-database timespan is strictly less than another timespan or a time
603 point.
605 Parameters
606 ----------
607 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
608 The timespan or time to relate to ``self``; either an instance of
609 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
610 a SQL column expression representing an `astropy.time.Time`.
612 Returns
613 -------
614 less : `sqlalchemy.sql.ColumnElement`
615 A boolean SQLAlchemy expression object.
617 Notes
618 -----
619 See `Timespan.__lt__` for edge-case behavior.
620 """
621 raise NotImplementedError()
623 @abstractmethod
624 def __gt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
625 """Return a SQLAlchemy expression for testing greater than.
627 Returns a SQLAlchemy expression representing a test for whether an
628 in-database timespan is strictly greater than another timespan or a
629 time point.
631 Parameters
632 ----------
633 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
634 The timespan or time to relate to ``self``; either an instance of
635 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
636 a SQL column expression representing an `astropy.time.Time`.
638 Returns
639 -------
640 greater : `sqlalchemy.sql.ColumnElement`
641 A boolean SQLAlchemy expression object.
643 Notes
644 -----
645 See `Timespan.__gt__` for edge-case behavior.
646 """
647 raise NotImplementedError()
649 @abstractmethod
650 def overlaps(self: _S, other: _S) -> sqlalchemy.sql.ColumnElement:
651 """Return a SQLAlchemy expression representing timespan overlaps.
653 Parameters
654 ----------
655 other : ``type(self)``
656 The timespan to overlap ``self`` with.
658 Returns
659 -------
660 overlap : `sqlalchemy.sql.ColumnElement`
661 A boolean SQLAlchemy expression object.
663 Notes
664 -----
665 See `Timespan.overlaps` for edge-case behavior.
666 """
667 raise NotImplementedError()
669 @abstractmethod
670 def contains(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
671 """Return a SQLAlchemy expression representing containment.
673 Returns a test for whether an in-database timespan contains another
674 timespan or a time point.
676 Parameters
677 ----------
678 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
679 The timespan or time to relate to ``self``; either an instance of
680 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
681 a SQL column expression representing an `astropy.time.Time`.
683 Returns
684 -------
685 contains : `sqlalchemy.sql.ColumnElement`
686 A boolean SQLAlchemy expression object.
688 Notes
689 -----
690 See `Timespan.contains` for edge-case behavior.
691 """
692 raise NotImplementedError()
695class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
696 """Representation of a time span as two separate fields.
698 An implementation of `TimespanDatabaseRepresentation` that simply stores
699 the endpoints in two separate fields.
701 This type should generally be accessed via
702 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
703 via the `fromSelectable` and `fromLiteral` methods.
705 Parameters
706 ----------
707 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
708 Tuple of SQLAlchemy objects representing the lower (inclusive) and
709 upper (exclusive) bounds, as 64-bit integer columns containing
710 nanoseconds.
711 name : `str`, optional
712 Name for the logical column; a part of the name for multi-column
713 representations. Defaults to ``cls.NAME``.
715 Notes
716 -----
717 ``NULL`` timespans are represented by having both fields set to ``NULL``;
718 setting only one to ``NULL`` is considered a corrupted state that should
719 only be possible if this interface is circumvented. `Timespan` instances
720 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
721 are set to fields mapped to the minimum and maximum value constants used
722 by our integer-time mapping.
723 """
725 def __init__(self, nsec: Tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
726 self._nsec = nsec
727 self._name = name
729 __slots__ = ("_nsec", "_name")
731 @classmethod
732 def makeFieldSpecs(cls, nullable: bool, name: Optional[str] = None, **kwargs: Any
733 ) -> Tuple[ddl.FieldSpec, ...]:
734 # Docstring inherited.
735 if name is None:
736 name = cls.NAME
737 return (
738 ddl.FieldSpec(
739 f"{name}_begin", dtype=sqlalchemy.BigInteger, nullable=nullable,
740 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
741 **kwargs,
742 ),
743 ddl.FieldSpec(
744 f"{name}_end", dtype=sqlalchemy.BigInteger, nullable=nullable,
745 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
746 **kwargs,
747 ),
748 )
750 @classmethod
751 def getFieldNames(cls, name: Optional[str] = None) -> Tuple[str, ...]:
752 # Docstring inherited.
753 if name is None:
754 name = cls.NAME
755 return (f"{name}_begin", f"{name}_end")
757 @classmethod
758 def update(cls, extent: Optional[Timespan], name: Optional[str] = None,
759 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
760 # Docstring inherited.
761 if name is None:
762 name = cls.NAME
763 if result is None:
764 result = {}
765 if extent is None:
766 begin_nsec = None
767 end_nsec = None
768 else:
769 begin_nsec = extent._nsec[0]
770 end_nsec = extent._nsec[1]
771 result[f"{name}_begin"] = begin_nsec
772 result[f"{name}_end"] = end_nsec
773 return result
775 @classmethod
776 def extract(cls, mapping: Mapping[str, Any], name: Optional[str] = None) -> Optional[Timespan]:
777 # Docstring inherited.
778 if name is None:
779 name = cls.NAME
780 begin_nsec = mapping[f"{name}_begin"]
781 end_nsec = mapping[f"{name}_end"]
782 if begin_nsec is None:
783 if end_nsec is not None:
784 raise RuntimeError(
785 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
786 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
787 )
788 return None
789 elif end_nsec is None:
790 raise RuntimeError(
791 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
792 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
793 )
794 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
796 @classmethod
797 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause,
798 name: Optional[str] = None) -> _CompoundTimespanDatabaseRepresentation:
799 # Docstring inherited.
800 if name is None:
801 name = cls.NAME
802 return cls(nsec=(selectable.columns[f"{name}_begin"], selectable.columns[f"{name}_end"]),
803 name=name)
805 @classmethod
806 def fromLiteral(cls, timespan: Timespan) -> _CompoundTimespanDatabaseRepresentation:
807 # Docstring inherited.
808 return cls(
809 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
810 name=cls.NAME,
811 )
813 @property
814 def name(self) -> str:
815 # Docstring inherited.
816 return self._name
818 def isNull(self) -> sqlalchemy.sql.ColumnElement:
819 # Docstring inherited.
820 return self._nsec[0].is_(None)
822 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
823 # Docstring inherited.
824 return self._nsec[0] >= self._nsec[1]
826 def __lt__(
827 self,
828 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
829 ) -> sqlalchemy.sql.ColumnElement:
830 # Docstring inherited.
831 # See comments in Timespan.__lt__ for why we use these exact
832 # expressions.
833 if isinstance(other, sqlalchemy.sql.ColumnElement):
834 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
835 else:
836 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
838 def __gt__(
839 self,
840 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
841 ) -> sqlalchemy.sql.ColumnElement:
842 # Docstring inherited.
843 # See comments in Timespan.__gt__ for why we use these exact
844 # expressions.
845 if isinstance(other, sqlalchemy.sql.ColumnElement):
846 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
847 else:
848 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
850 def overlaps(self, other: _CompoundTimespanDatabaseRepresentation) -> sqlalchemy.sql.ColumnElement:
851 # Docstring inherited.
852 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
854 def contains(
855 self,
856 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
857 ) -> sqlalchemy.sql.ColumnElement:
858 # Docstring inherited.
859 if isinstance(other, sqlalchemy.sql.ColumnElement):
860 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
861 else:
862 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
864 def flatten(self, name: Optional[str] = None) -> Iterator[sqlalchemy.sql.ColumnElement]:
865 # Docstring inherited.
866 if name is None:
867 yield from self._nsec
868 else:
869 yield self._nsec[0].label(f"{name}_begin")
870 yield self._nsec[1].label(f"{name}_end")
873TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation