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

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 Any,
32 ClassVar,
33 Dict,
34 Generator,
35 Iterator,
36 Mapping,
37 Optional,
38 Tuple,
39 Type,
40 TypeVar,
41 Union,
42)
44import astropy.time
45import astropy.utils.exceptions
46import sqlalchemy
47import warnings
49# As of astropy 4.2, the erfa interface is shipped independently and
50# ErfaWarning is no longer an AstropyWarning
51try:
52 import erfa
53except ImportError:
54 erfa = None
56from . import ddl
57from .time_utils import TimeConverter
58from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace
59from .utils import cached_getter
62class _SpecialTimespanBound(enum.Enum):
63 """Enumeration to provide a singleton value for empty timespan bounds.
65 This enum's only member should generally be accessed via the
66 `Timespan.EMPTY` alias.
67 """
69 EMPTY = enum.auto()
70 """The value used for both `Timespan.begin` and `Timespan.end` for empty
71 Timespans that contain no points.
72 """
75TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
78class Timespan:
79 """A half-open time interval with nanosecond precision.
81 Parameters
82 ----------
83 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
84 Minimum timestamp in the interval (inclusive). `None` indicates that
85 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
86 timespan contains no times; if this is used as either bound, the other
87 bound is ignored.
88 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
89 Maximum timestamp in the interval (exclusive). `None` indicates that
90 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
91 creates an empty timespan.
92 padInstantaneous : `bool`, optional
93 If `True` (default) and ``begin == end`` *after discretization to
94 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
95 finite-duration timespan. If `False`, ``begin == end`` evaluates to
96 the empty timespan.
97 _nsec : `tuple` of `int`, optional
98 Integer nanosecond representation, for internal use by `Timespan` and
99 `TimespanDatabaseRepresentation` implementation only. If provided,
100 all other arguments are are ignored.
102 Raises
103 ------
104 TypeError
105 Raised if ``begin`` or ``end`` has a type other than
106 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
107 ValueError
108 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
109 supported by this class.
111 Notes
112 -----
113 Timespans are half-open intervals, i.e. ``[begin, end)``.
115 Any timespan with ``begin > end`` after nanosecond discretization
116 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
117 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
118 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
119 contained by all other timespans (including itself). It is also disjoint
120 with all timespans (including itself), and hence does not overlap any
121 timespan - this is the only case where ``contains`` does not imply
122 ``overlaps``.
124 Finite timespan bounds are represented internally as integer nanoseconds,
125 and hence construction from `astropy.time.Time` (which has picosecond
126 accuracy) can involve a loss of precision. This is of course
127 deterministic, so any `astropy.time.Time` value is always mapped
128 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
129 timespans that are empty at full precision (``begin > end``,
130 ``begin - end < 1ns``) may be finite after discretization. In all other
131 cases, the relationships between full-precision timespans should be
132 preserved even if the values are not.
134 The `astropy.time.Time` bounds that can be obtained after construction from
135 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
136 exactly when used to construct other `Timespan` instances.
137 """
138 def __init__(self, begin: TimespanBound, end: TimespanBound, padInstantaneous: bool = True,
139 _nsec: Optional[Tuple[int, int]] = None):
140 converter = TimeConverter()
141 if _nsec is None:
142 begin_nsec: int
143 if begin is None:
144 begin_nsec = converter.min_nsec
145 elif begin is self.EMPTY:
146 begin_nsec = converter.max_nsec
147 elif isinstance(begin, astropy.time.Time):
148 begin_nsec = converter.astropy_to_nsec(begin)
149 else:
150 raise TypeError(
151 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
152 )
153 end_nsec: int
154 if end is None:
155 end_nsec = converter.max_nsec
156 elif end is self.EMPTY:
157 end_nsec = converter.min_nsec
158 elif isinstance(end, astropy.time.Time):
159 end_nsec = converter.astropy_to_nsec(end)
160 else:
161 raise TypeError(
162 f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}."
163 )
164 if begin_nsec == end_nsec:
165 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
166 with warnings.catch_warnings():
167 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
168 if erfa is not None:
169 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
170 if begin is not None and begin < converter.epoch:
171 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
172 if end is not None and end > converter.max_time:
173 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
174 raise ValueError("Infinite instantaneous timespans are not supported.")
175 elif padInstantaneous:
176 end_nsec += 1
177 if end_nsec == converter.max_nsec:
178 raise ValueError(
179 f"Cannot construct near-instantaneous timespan at {end}; "
180 "within one ns of maximum time."
181 )
182 _nsec = (begin_nsec, end_nsec)
183 if _nsec[0] >= _nsec[1]:
184 # Standardizing all empty timespans to the same underlying values
185 # here simplifies all other operations (including interactions
186 # with TimespanDatabaseRepresentation implementations).
187 _nsec = (converter.max_nsec, converter.min_nsec)
188 self._nsec = _nsec
190 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
192 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
194 @classmethod
195 def makeEmpty(cls) -> Timespan:
196 """Construct an empty timespan.
198 Returns
199 -------
200 empty : `Timespan`
201 A timespan that is contained by all timespans (including itself)
202 and overlaps no other timespans (including itself).
203 """
204 converter = TimeConverter()
205 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
207 @classmethod
208 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
209 """Construct a timespan that approximates an instant in time by a
210 minimum-possible (1 ns) duration timespan.
212 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
213 but may be slightly more efficient.
215 Parameters
216 ----------
217 time : `astropy.time.Time`
218 Time to use for the lower bound.
220 Returns
221 -------
222 instant : `Timespan`
223 A ``[time, time + 1ns)`` timespan.
224 """
225 converter = TimeConverter()
226 nsec = converter.astropy_to_nsec(time)
227 if nsec == converter.max_nsec - 1:
228 raise ValueError(
229 f"Cannot construct near-instantaneous timespan at {time}; "
230 "within one ns of maximum time."
231 )
232 return Timespan(None, None, _nsec=(nsec, nsec + 1))
234 @property # type: ignore
235 @cached_getter
236 def begin(self) -> TimespanBound:
237 """Minimum timestamp in the interval, inclusive.
239 If this bound is finite, this is an `astropy.time.Time` instance.
240 If the timespan is unbounded from below, this is `None`.
241 If the timespan is empty, this is the special value `Timespan.EMPTY`.
242 """
243 if self.isEmpty():
244 return self.EMPTY
245 elif self._nsec[0] == TimeConverter().min_nsec:
246 return None
247 else:
248 return TimeConverter().nsec_to_astropy(self._nsec[0])
250 @property # type: ignore
251 @cached_getter
252 def end(self) -> TimespanBound:
253 """Maximum timestamp in the interval, exclusive.
255 If this bound is finite, this is an `astropy.time.Time` instance.
256 If the timespan is unbounded from above, this is `None`.
257 If the timespan is empty, this is the special value `Timespan.EMPTY`.
258 """
259 if self.isEmpty():
260 return self.EMPTY
261 elif self._nsec[1] == TimeConverter().max_nsec:
262 return None
263 else:
264 return TimeConverter().nsec_to_astropy(self._nsec[1])
266 def isEmpty(self) -> bool:
267 """Test whether ``self`` is the empty timespan (`bool`).
268 """
269 return self._nsec[0] >= self._nsec[1]
271 def __str__(self) -> str:
272 if self.isEmpty():
273 return "(empty)"
274 # Trap dubious year warnings in case we have timespans from
275 # simulated data in the future
276 with warnings.catch_warnings():
277 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
278 if erfa is not None:
279 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
280 if self.begin is None:
281 head = "(-∞, "
282 else:
283 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
284 head = f"[{self.begin.tai.isot}, "
285 if self.end is None:
286 tail = "∞)"
287 else:
288 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
289 tail = f"{self.end.tai.isot})"
290 return head + tail
292 def __repr__(self) -> str:
293 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
294 # simulate our own here to make Timespan's __repr__ eval-friendly.
295 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
296 # eval-friendly __repr__.
297 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
298 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
299 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
300 return f"Timespan(begin={begin}, end={end})"
302 def __eq__(self, other: Any) -> bool:
303 if not isinstance(other, Timespan):
304 return False
305 # Correctness of this simple implementation depends on __init__
306 # standardizing all empty timespans to a single value.
307 return self._nsec == other._nsec
309 def __hash__(self) -> int:
310 # Correctness of this simple implementation depends on __init__
311 # standardizing all empty timespans to a single value.
312 return hash(self._nsec)
314 def __reduce__(self) -> tuple:
315 return (Timespan, (None, None, False, self._nsec))
317 def __lt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
318 """Test whether a Timespan's bounds are strictly less than the given
319 time or timespan.
322 Parameters
323 ----------
324 other : `Timespan` or `astropy.time.Time`.
325 Timespan or instant in time to relate to ``self``.
327 Returns
328 -------
329 less : `bool`
330 The result of the less-than test. `False` if either operand is
331 empty.
332 """
333 # First term in each expression below is the "normal" one; the second
334 # ensures correct behavior for empty timespans. It's important that
335 # the second uses a strict inequality to make sure inf == inf isn't in
336 # play, and it's okay for the second to use a strict inequality only
337 # because we know non-empty Timespans have nonzero duration, and hence
338 # the second term is never false for non-empty timespans unless the
339 # first term is also false.
340 if isinstance(other, astropy.time.Time):
341 nsec = TimeConverter().astropy_to_nsec(other)
342 return self._nsec[1] <= nsec and self._nsec[0] < nsec
343 else:
344 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
346 def __gt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
347 """Test whether a Timespan's bounds are strictly greater than the given
348 time or timespan.
351 Parameters
352 ----------
353 other : `Timespan` or `astropy.time.Time`.
354 Timespan or instant in time to relate to ``self``.
356 Returns
357 -------
358 greater : `bool`
359 The result of the greater-than test. `False` if either operand is
360 empty.
361 """
362 # First term in each expression below is the "normal" one; the second
363 # ensures correct behavior for empty timespans. It's important that
364 # the second uses a strict inequality to make sure inf == inf isn't in
365 # play, and it's okay for the second to use a strict inequality only
366 # because we know non-empty Timespans have nonzero duration, and hence
367 # the second term is never false for non-empty timespans unless the
368 # first term is also false.
369 if isinstance(other, astropy.time.Time):
370 nsec = TimeConverter().astropy_to_nsec(other)
371 return self._nsec[0] > nsec and self._nsec[1] > nsec
372 else:
373 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
375 def overlaps(self, other: Timespan) -> bool:
376 """Test whether the intersection of this Timespan with another
377 is empty.
379 Parameters
380 ----------
381 other : `Timespan`
382 Timespan to relate to ``self``.
384 Returns
385 -------
386 overlaps : `bool`
387 The result of the overlap test.
389 Notes
390 -----
391 If either ``self`` or ``other`` is empty, the result is always `False`.
392 In all other cases, ``self.contains(other)`` being `True` implies that
393 ``self.overlaps(other)`` is also `True`.
394 """
395 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
397 def contains(self, other: Union[astropy.time.Time, Timespan]) -> bool:
398 """Test whether the intersection of this timespan with another timespan
399 or point is equal to the other one.
401 Parameters
402 ----------
403 other : `Timespan` or `astropy.time.Time`.
404 Timespan or instant in time to relate to ``self``.
406 Returns
407 -------
408 overlaps : `bool`
409 The result of the contains test.
411 Notes
412 -----
413 If ``other`` is empty, `True` is always returned. In all other cases,
414 ``self.contains(other)`` being `True` implies that
415 ``self.overlaps(other)`` is also `True`.
417 Testing whether an instantaneous `astropy.time.Time` value is contained
418 in a timespan is not equivalent to testing a timespan constructed via
419 `Timespan.fromInstant`, because Timespan cannot exactly represent
420 zero-duration intervals. In particular, ``[a, b)`` contains the time
421 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
422 by `Timespan.fromInstant(b)``.
423 """
424 if isinstance(other, astropy.time.Time):
425 nsec = TimeConverter().astropy_to_nsec(other)
426 return self._nsec[0] <= nsec and self._nsec[1] > nsec
427 else:
428 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
430 def intersection(self, *args: Timespan) -> Timespan:
431 """Return a new `Timespan` that is contained by all of the given ones.
433 Parameters
434 ----------
435 *args
436 All positional arguments are `Timespan` instances.
438 Returns
439 -------
440 intersection : `Timespan`
441 The intersection timespan.
442 """
443 if not args:
444 return self
445 lowers = [self._nsec[0]]
446 lowers.extend(ts._nsec[0] for ts in args)
447 uppers = [self._nsec[1]]
448 uppers.extend(ts._nsec[1] for ts in args)
449 nsec = (max(*lowers), min(*uppers))
450 return Timespan(begin=None, end=None, _nsec=nsec)
452 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
453 """Return the one or two timespans that cover the interval(s) that are
454 in ``self`` but not ``other``.
456 This is implemented as a generator because the result may be zero, one,
457 or two `Timespan` objects, depending on the relationship between the
458 operands.
460 Parameters
461 ----------
462 other : `Timespan`
463 Timespan to subtract.
465 Yields
466 ------
467 result : `Timespan`
468 A `Timespan` that is contained by ``self`` but does not overlap
469 ``other``. Guaranteed not to be empty.
470 """
471 intersection = self.intersection(other)
472 if intersection.isEmpty():
473 yield self
474 elif intersection == self:
475 yield from ()
476 else:
477 if intersection._nsec[0] > self._nsec[0]:
478 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
479 if intersection._nsec[1] < self._nsec[1]:
480 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
483_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
486class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]):
487 """An interface that encapsulates how timespans are represented in a
488 database engine.
490 Most of this class's interface is comprised of classmethods. Instances
491 can be constructed via the `fromSelectable` or `fromLiteral` methods as a
492 way to include timespan overlap operations in query JOIN or WHERE clauses.
494 Notes
495 -----
496 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
497 same interval definitions and edge-case behavior as the `Timespan` class.
498 They are also guaranteed to round-trip `Timespan` instances exactly.
499 """
501 NAME: ClassVar[str] = "timespan"
502 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL
504 Compound: ClassVar[Type[TimespanDatabaseRepresentation]]
505 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
506 uses two separate fields for the begin (inclusive) and end (excusive)
507 endpoints.
509 This implementation should be compatible with any SQL database, and should
510 generally be used when a database-specific implementation is not available.
511 """
513 __slots__ = ()
515 @classmethod
516 @abstractmethod
517 def fromLiteral(cls: Type[_S], timespan: Timespan) -> _S:
518 """Construct a database timespan representation from a literal
519 `Timespan` instance.
521 Parameters
522 ----------
523 timespan : `Timespan`
524 Literal timespan to convert.
526 Returns
527 -------
528 tsRepr : `TimespanDatabaseRepresentation`
529 A timespan expression object backed by `sqlalchemy.sql.literal`
530 column expressions.
531 """
532 raise NotImplementedError()
534 @abstractmethod
535 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
536 """Return a boolean SQLAlchemy expression that tests whether the
537 timespan is empty.
539 Returns
540 -------
541 empty : `sqlalchemy.sql.ColumnElement`
542 A boolean SQLAlchemy expression object.
543 """
544 raise NotImplementedError()
546 @abstractmethod
547 def __lt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
548 """Return a SQLAlchemy expression representing a test for whether an
549 in-database timespan is strictly less than another timespan or a time
550 point.
552 Parameters
553 ----------
554 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
555 The timespan or time to relate to ``self``; either an instance of
556 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
557 a SQL column expression representing an `astropy.time.Time`.
559 Returns
560 -------
561 less : `sqlalchemy.sql.ColumnElement`
562 A boolean SQLAlchemy expression object.
564 Notes
565 -----
566 See `Timespan.__lt__` for edge-case behavior.
567 """
568 raise NotImplementedError()
570 @abstractmethod
571 def __gt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
572 """Return a SQLAlchemy expression representing a test for whether an
573 in-database timespan is strictly greater than another timespan or a
574 time point.
576 Parameters
577 ----------
578 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
579 The timespan or time to relate to ``self``; either an instance of
580 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
581 a SQL column expression representing an `astropy.time.Time`.
583 Returns
584 -------
585 greater : `sqlalchemy.sql.ColumnElement`
586 A boolean SQLAlchemy expression object.
588 Notes
589 -----
590 See `Timespan.__gt__` for edge-case behavior.
591 """
592 raise NotImplementedError()
594 @abstractmethod
595 def overlaps(self: _S, other: _S) -> sqlalchemy.sql.ColumnElement:
596 """Return a SQLAlchemy expression representing an overlap operation on
597 timespans.
599 Parameters
600 ----------
601 other : ``type(self)``
602 The timespan to overlap ``self`` with.
604 Returns
605 -------
606 overlap : `sqlalchemy.sql.ColumnElement`
607 A boolean SQLAlchemy expression object.
609 Notes
610 -----
611 See `Timespan.overlaps` for edge-case behavior.
612 """
613 raise NotImplementedError()
615 @abstractmethod
616 def contains(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
617 """Return a SQLAlchemy expression representing a test for whether an
618 in-database timespan contains another timespan or a time point.
620 Parameters
621 ----------
622 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
623 The timespan or time to relate to ``self``; either an instance of
624 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
625 a SQL column expression representing an `astropy.time.Time`.
627 Returns
628 -------
629 contains : `sqlalchemy.sql.ColumnElement`
630 A boolean SQLAlchemy expression object.
632 Notes
633 -----
634 See `Timespan.contains` for edge-case behavior.
635 """
636 raise NotImplementedError()
639class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
640 """An implementation of `TimespanDatabaseRepresentation` that simply stores
641 the endpoints in two separate fields.
643 This type should generally be accessed via
644 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
645 via the `fromSelectable` and `fromLiteral` methods.
647 Parameters
648 ----------
649 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
650 Tuple of SQLAlchemy objects representing the lower (inclusive) and
651 upper (exclusive) bounds, as 64-bit integer columns containing
652 nanoseconds.
653 name : `str`, optional
654 Name for the logical column; a part of the name for multi-column
655 representations. Defaults to ``cls.NAME``.
657 Notes
658 -----
659 ``NULL`` timespans are represented by having both fields set to ``NULL``;
660 setting only one to ``NULL`` is considered a corrupted state that should
661 only be possible if this interface is circumvented. `Timespan` instances
662 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
663 are set to fields mapped to the minimum and maximum value constants used
664 by our integer-time mapping.
665 """
666 def __init__(self, nsec: Tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
667 self._nsec = nsec
668 self._name = name
670 __slots__ = ("_nsec", "_name")
672 @classmethod
673 def makeFieldSpecs(cls, nullable: bool, name: Optional[str] = None, **kwargs: Any
674 ) -> Tuple[ddl.FieldSpec, ...]:
675 # Docstring inherited.
676 if name is None:
677 name = cls.NAME
678 return (
679 ddl.FieldSpec(
680 f"{name}_begin", dtype=sqlalchemy.BigInteger, nullable=nullable,
681 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
682 **kwargs,
683 ),
684 ddl.FieldSpec(
685 f"{name}_end", dtype=sqlalchemy.BigInteger, nullable=nullable,
686 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
687 **kwargs,
688 ),
689 )
691 @classmethod
692 def getFieldNames(cls, name: Optional[str] = None) -> Tuple[str, ...]:
693 # Docstring inherited.
694 if name is None:
695 name = cls.NAME
696 return (f"{name}_begin", f"{name}_end")
698 @classmethod
699 def update(cls, extent: Optional[Timespan], name: Optional[str] = None,
700 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
701 # Docstring inherited.
702 if name is None:
703 name = cls.NAME
704 if result is None:
705 result = {}
706 if extent is None:
707 begin_nsec = None
708 end_nsec = None
709 else:
710 begin_nsec = extent._nsec[0]
711 end_nsec = extent._nsec[1]
712 result[f"{name}_begin"] = begin_nsec
713 result[f"{name}_end"] = end_nsec
714 return result
716 @classmethod
717 def extract(cls, mapping: Mapping[str, Any], name: Optional[str] = None) -> Optional[Timespan]:
718 # Docstring inherited.
719 if name is None:
720 name = cls.NAME
721 begin_nsec = mapping[f"{name}_begin"]
722 end_nsec = mapping[f"{name}_end"]
723 if begin_nsec is None:
724 if end_nsec is not None:
725 raise RuntimeError(
726 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
727 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
728 )
729 return None
730 elif end_nsec is None:
731 raise RuntimeError(
732 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
733 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
734 )
735 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
737 @classmethod
738 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause,
739 name: Optional[str] = None) -> _CompoundTimespanDatabaseRepresentation:
740 # Docstring inherited.
741 if name is None:
742 name = cls.NAME
743 return cls(nsec=(selectable.columns[f"{name}_begin"], selectable.columns[f"{name}_end"]),
744 name=name)
746 @classmethod
747 def fromLiteral(cls, timespan: Timespan) -> _CompoundTimespanDatabaseRepresentation:
748 # Docstring inherited.
749 return cls(
750 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
751 name=cls.NAME,
752 )
754 @property
755 def name(self) -> str:
756 # Docstring inherited.
757 return self._name
759 def isNull(self) -> sqlalchemy.sql.ColumnElement:
760 # Docstring inherited.
761 return self._nsec[0].is_(None)
763 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
764 # Docstring inherited.
765 return self._nsec[0] >= self._nsec[1]
767 def __lt__(
768 self,
769 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
770 ) -> sqlalchemy.sql.ColumnElement:
771 # Docstring inherited.
772 # See comments in Timespan.__lt__ for why we use these exact
773 # expressions.
774 if isinstance(other, sqlalchemy.sql.ColumnElement):
775 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
776 else:
777 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
779 def __gt__(
780 self,
781 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
782 ) -> sqlalchemy.sql.ColumnElement:
783 # Docstring inherited.
784 # See comments in Timespan.__gt__ for why we use these exact
785 # expressions.
786 if isinstance(other, sqlalchemy.sql.ColumnElement):
787 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
788 else:
789 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
791 def overlaps(self, other: _CompoundTimespanDatabaseRepresentation) -> sqlalchemy.sql.ColumnElement:
792 # Docstring inherited.
793 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
795 def contains(
796 self,
797 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
798 ) -> sqlalchemy.sql.ColumnElement:
799 # Docstring inherited.
800 if isinstance(other, sqlalchemy.sql.ColumnElement):
801 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
802 else:
803 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
805 def flatten(self, name: Optional[str] = None) -> Iterator[sqlalchemy.sql.ColumnElement]:
806 # Docstring inherited.
807 if name is None:
808 yield from self._nsec
809 else:
810 yield self._nsec[0].label(f"{name}_begin")
811 yield self._nsec[1].label(f"{name}_end")
814TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation