Coverage for python/lsst/daf/butler/core/timespan.py: 29%
296 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-19 12:13 +0000
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-19 12:13 +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 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)
28import enum
29import warnings
30from abc import abstractmethod
31from typing import (
32 TYPE_CHECKING,
33 Any,
34 ClassVar,
35 Dict,
36 Generator,
37 Iterator,
38 List,
39 Mapping,
40 Optional,
41 Tuple,
42 Type,
43 TypeVar,
44 Union,
45)
47import astropy.time
48import astropy.utils.exceptions
49import sqlalchemy
50import yaml
52# As of astropy 4.2, the erfa interface is shipped independently and
53# ErfaWarning is no longer an AstropyWarning
54try:
55 import erfa
56except ImportError:
57 erfa = None
59from lsst.utils.classes import cached_getter
61from . import ddl
62from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace
63from .json import from_json_generic, to_json_generic
64from .time_utils import TimeConverter
66if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 66 ↛ 67line 66 didn't jump to line 67, because the condition on line 66 was never true
67 from ..registry import Registry
68 from .dimensions import DimensionUniverse
71class _SpecialTimespanBound(enum.Enum):
72 """Enumeration to provide a singleton value for empty timespan bounds.
74 This enum's only member should generally be accessed via the
75 `Timespan.EMPTY` alias.
76 """
78 EMPTY = enum.auto()
79 """The value used for both `Timespan.begin` and `Timespan.end` for empty
80 Timespans that contain no points.
81 """
84TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
87class Timespan:
88 """A half-open time interval with nanosecond precision.
90 Parameters
91 ----------
92 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
93 Minimum timestamp in the interval (inclusive). `None` indicates that
94 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
95 timespan contains no times; if this is used as either bound, the other
96 bound is ignored.
97 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
98 Maximum timestamp in the interval (exclusive). `None` indicates that
99 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
100 creates an empty timespan.
101 padInstantaneous : `bool`, optional
102 If `True` (default) and ``begin == end`` *after discretization to
103 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
104 finite-duration timespan. If `False`, ``begin == end`` evaluates to
105 the empty timespan.
106 _nsec : `tuple` of `int`, optional
107 Integer nanosecond representation, for internal use by `Timespan` and
108 `TimespanDatabaseRepresentation` implementation only. If provided,
109 all other arguments are are ignored.
111 Raises
112 ------
113 TypeError
114 Raised if ``begin`` or ``end`` has a type other than
115 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
116 ValueError
117 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
118 supported by this class.
120 Notes
121 -----
122 Timespans are half-open intervals, i.e. ``[begin, end)``.
124 Any timespan with ``begin > end`` after nanosecond discretization
125 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
126 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
127 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
128 contained by all other timespans (including itself). It is also disjoint
129 with all timespans (including itself), and hence does not overlap any
130 timespan - this is the only case where ``contains`` does not imply
131 ``overlaps``.
133 Finite timespan bounds are represented internally as integer nanoseconds,
134 and hence construction from `astropy.time.Time` (which has picosecond
135 accuracy) can involve a loss of precision. This is of course
136 deterministic, so any `astropy.time.Time` value is always mapped
137 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
138 timespans that are empty at full precision (``begin > end``,
139 ``begin - end < 1ns``) may be finite after discretization. In all other
140 cases, the relationships between full-precision timespans should be
141 preserved even if the values are not.
143 The `astropy.time.Time` bounds that can be obtained after construction from
144 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
145 exactly when used to construct other `Timespan` instances.
146 """
148 def __init__(
149 self,
150 begin: TimespanBound,
151 end: TimespanBound,
152 padInstantaneous: bool = True,
153 _nsec: Optional[Tuple[int, int]] = None,
154 ):
155 converter = TimeConverter()
156 if _nsec is None:
157 begin_nsec: int
158 if begin is None:
159 begin_nsec = converter.min_nsec
160 elif begin is self.EMPTY:
161 begin_nsec = converter.max_nsec
162 elif isinstance(begin, astropy.time.Time):
163 begin_nsec = converter.astropy_to_nsec(begin)
164 else:
165 raise TypeError(
166 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
167 )
168 end_nsec: int
169 if end is None:
170 end_nsec = converter.max_nsec
171 elif end is self.EMPTY:
172 end_nsec = converter.min_nsec
173 elif isinstance(end, astropy.time.Time):
174 end_nsec = converter.astropy_to_nsec(end)
175 else:
176 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.")
177 if begin_nsec == end_nsec:
178 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
179 with warnings.catch_warnings():
180 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
181 if erfa is not None:
182 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
183 if begin is not None and begin < converter.epoch:
184 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
185 if end is not None and end > converter.max_time:
186 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
187 raise ValueError("Infinite instantaneous timespans are not supported.")
188 elif padInstantaneous:
189 end_nsec += 1
190 if end_nsec == converter.max_nsec:
191 raise ValueError(
192 f"Cannot construct near-instantaneous timespan at {end}; "
193 "within one ns of maximum time."
194 )
195 _nsec = (begin_nsec, end_nsec)
196 if _nsec[0] >= _nsec[1]:
197 # Standardizing all empty timespans to the same underlying values
198 # here simplifies all other operations (including interactions
199 # with TimespanDatabaseRepresentation implementations).
200 _nsec = (converter.max_nsec, converter.min_nsec)
201 self._nsec = _nsec
203 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
205 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
207 # YAML tag name for Timespan
208 yaml_tag = "!lsst.daf.butler.Timespan"
210 @classmethod
211 def makeEmpty(cls) -> Timespan:
212 """Construct an empty timespan.
214 Returns
215 -------
216 empty : `Timespan`
217 A timespan that is contained by all timespans (including itself)
218 and overlaps no other timespans (including itself).
219 """
220 converter = TimeConverter()
221 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
223 @classmethod
224 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
225 """Construct a timespan that approximates an instant in time.
227 This is done by constructing a minimum-possible (1 ns) duration
228 timespan.
230 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
231 but may be slightly more efficient.
233 Parameters
234 ----------
235 time : `astropy.time.Time`
236 Time to use for the lower bound.
238 Returns
239 -------
240 instant : `Timespan`
241 A ``[time, time + 1ns)`` timespan.
242 """
243 converter = TimeConverter()
244 nsec = converter.astropy_to_nsec(time)
245 if nsec == converter.max_nsec - 1:
246 raise ValueError(
247 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time."
248 )
249 return Timespan(None, None, _nsec=(nsec, nsec + 1))
251 @property # type: ignore
252 @cached_getter
253 def begin(self) -> TimespanBound:
254 """Minimum timestamp in the interval, inclusive.
256 If this bound is finite, this is an `astropy.time.Time` instance.
257 If the timespan is unbounded from below, this is `None`.
258 If the timespan is empty, this is the special value `Timespan.EMPTY`.
259 """
260 if self.isEmpty():
261 return self.EMPTY
262 elif self._nsec[0] == TimeConverter().min_nsec:
263 return None
264 else:
265 return TimeConverter().nsec_to_astropy(self._nsec[0])
267 @property # type: ignore
268 @cached_getter
269 def end(self) -> TimespanBound:
270 """Maximum timestamp in the interval, exclusive.
272 If this bound is finite, this is an `astropy.time.Time` instance.
273 If the timespan is unbounded from above, this is `None`.
274 If the timespan is empty, this is the special value `Timespan.EMPTY`.
275 """
276 if self.isEmpty():
277 return self.EMPTY
278 elif self._nsec[1] == TimeConverter().max_nsec:
279 return None
280 else:
281 return TimeConverter().nsec_to_astropy(self._nsec[1])
283 def isEmpty(self) -> bool:
284 """Test whether ``self`` is the empty timespan (`bool`)."""
285 return self._nsec[0] >= self._nsec[1]
287 def __str__(self) -> str:
288 if self.isEmpty():
289 return "(empty)"
290 # Trap dubious year warnings in case we have timespans from
291 # simulated data in the future
292 with warnings.catch_warnings():
293 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
294 if erfa is not None:
295 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
296 if self.begin is None:
297 head = "(-∞, "
298 else:
299 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
300 head = f"[{self.begin.tai.isot}, "
301 if self.end is None:
302 tail = "∞)"
303 else:
304 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
305 tail = f"{self.end.tai.isot})"
306 return head + tail
308 def __repr__(self) -> str:
309 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
310 # simulate our own here to make Timespan's __repr__ eval-friendly.
311 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
312 # eval-friendly __repr__.
313 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
314 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
315 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
316 return f"Timespan(begin={begin}, end={end})"
318 def __eq__(self, other: Any) -> bool:
319 if not isinstance(other, Timespan):
320 return False
321 # Correctness of this simple implementation depends on __init__
322 # standardizing all empty timespans to a single value.
323 return self._nsec == other._nsec
325 def __hash__(self) -> int:
326 # Correctness of this simple implementation depends on __init__
327 # standardizing all empty timespans to a single value.
328 return hash(self._nsec)
330 def __reduce__(self) -> tuple:
331 return (Timespan, (None, None, False, self._nsec))
333 def __lt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
334 """Test if a Timespan's bounds are strictly less than the given time.
336 Parameters
337 ----------
338 other : `Timespan` or `astropy.time.Time`.
339 Timespan or instant in time to relate to ``self``.
341 Returns
342 -------
343 less : `bool`
344 The result of the less-than test. `False` if either operand is
345 empty.
346 """
347 # First term in each expression below is the "normal" one; the second
348 # ensures correct behavior for empty timespans. It's important that
349 # the second uses a strict inequality to make sure inf == inf isn't in
350 # play, and it's okay for the second to use a strict inequality only
351 # because we know non-empty Timespans have nonzero duration, and hence
352 # the second term is never false for non-empty timespans unless the
353 # first term is also false.
354 if isinstance(other, astropy.time.Time):
355 nsec = TimeConverter().astropy_to_nsec(other)
356 return self._nsec[1] <= nsec and self._nsec[0] < nsec
357 else:
358 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
360 def __gt__(self, other: Union[astropy.time.Time, Timespan]) -> bool:
361 """Test if a Timespan's bounds are strictly greater than given time.
363 Parameters
364 ----------
365 other : `Timespan` or `astropy.time.Time`.
366 Timespan or instant in time to relate to ``self``.
368 Returns
369 -------
370 greater : `bool`
371 The result of the greater-than test. `False` if either operand is
372 empty.
373 """
374 # First term in each expression below is the "normal" one; the second
375 # ensures correct behavior for empty timespans. It's important that
376 # the second uses a strict inequality to make sure inf == inf isn't in
377 # play, and it's okay for the second to use a strict inequality only
378 # because we know non-empty Timespans have nonzero duration, and hence
379 # the second term is never false for non-empty timespans unless the
380 # first term is also false.
381 if isinstance(other, astropy.time.Time):
382 nsec = TimeConverter().astropy_to_nsec(other)
383 return self._nsec[0] > nsec and self._nsec[1] > nsec
384 else:
385 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
387 def overlaps(self, other: Timespan) -> bool:
388 """Test if the intersection of this Timespan with another is empty.
390 Parameters
391 ----------
392 other : `Timespan`
393 Timespan to relate to ``self``.
395 Returns
396 -------
397 overlaps : `bool`
398 The result of the overlap test.
400 Notes
401 -----
402 If either ``self`` or ``other`` is empty, the result is always `False`.
403 In all other cases, ``self.contains(other)`` being `True` implies that
404 ``self.overlaps(other)`` is also `True`.
405 """
406 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
408 def contains(self, other: Union[astropy.time.Time, Timespan]) -> bool:
409 """Test if the supplied timespan is within this one.
411 Tests whether the intersection of this timespan with another timespan
412 or point is equal to the other one.
414 Parameters
415 ----------
416 other : `Timespan` or `astropy.time.Time`.
417 Timespan or instant in time to relate to ``self``.
419 Returns
420 -------
421 overlaps : `bool`
422 The result of the contains test.
424 Notes
425 -----
426 If ``other`` is empty, `True` is always returned. In all other cases,
427 ``self.contains(other)`` being `True` implies that
428 ``self.overlaps(other)`` is also `True`.
430 Testing whether an instantaneous `astropy.time.Time` value is contained
431 in a timespan is not equivalent to testing a timespan constructed via
432 `Timespan.fromInstant`, because Timespan cannot exactly represent
433 zero-duration intervals. In particular, ``[a, b)`` contains the time
434 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
435 by `Timespan.fromInstant(b)``.
436 """
437 if isinstance(other, astropy.time.Time):
438 nsec = TimeConverter().astropy_to_nsec(other)
439 return self._nsec[0] <= nsec and self._nsec[1] > nsec
440 else:
441 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
443 def intersection(self, *args: Timespan) -> Timespan:
444 """Return a new `Timespan` that is contained by all of the given ones.
446 Parameters
447 ----------
448 *args
449 All positional arguments are `Timespan` instances.
451 Returns
452 -------
453 intersection : `Timespan`
454 The intersection timespan.
455 """
456 if not args:
457 return self
458 lowers = [self._nsec[0]]
459 lowers.extend(ts._nsec[0] for ts in args)
460 uppers = [self._nsec[1]]
461 uppers.extend(ts._nsec[1] for ts in args)
462 nsec = (max(*lowers), min(*uppers))
463 return Timespan(begin=None, end=None, _nsec=nsec)
465 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
466 """Return the one or two timespans that cover the interval(s).
468 The interval is defined as one that is in ``self`` but not ``other``.
470 This is implemented as a generator because the result may be zero, one,
471 or two `Timespan` objects, depending on the relationship between the
472 operands.
474 Parameters
475 ----------
476 other : `Timespan`
477 Timespan to subtract.
479 Yields
480 ------
481 result : `Timespan`
482 A `Timespan` that is contained by ``self`` but does not overlap
483 ``other``. Guaranteed not to be empty.
484 """
485 intersection = self.intersection(other)
486 if intersection.isEmpty():
487 yield self
488 elif intersection == self:
489 yield from ()
490 else:
491 if intersection._nsec[0] > self._nsec[0]:
492 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
493 if intersection._nsec[1] < self._nsec[1]:
494 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
496 def to_simple(self, minimal: bool = False) -> List[int]:
497 """Return simple python type form suitable for serialization.
499 Parameters
500 ----------
501 minimal : `bool`, optional
502 Use minimal serialization. Has no effect on for this class.
504 Returns
505 -------
506 simple : `list` of `int`
507 The internal span as integer nanoseconds.
508 """
509 # Return the internal nanosecond form rather than astropy ISO string
510 return list(self._nsec)
512 @classmethod
513 def from_simple(
514 cls,
515 simple: List[int],
516 universe: Optional[DimensionUniverse] = None,
517 registry: Optional[Registry] = None,
518 ) -> Timespan:
519 """Construct a new object from simplified form.
521 Designed to use the data returned from the `to_simple` method.
523 Parameters
524 ----------
525 simple : `list` of `int`
526 The values returned by `to_simple()`.
527 universe : `DimensionUniverse`, optional
528 Unused.
529 registry : `lsst.daf.butler.Registry`, optional
530 Unused.
532 Returns
533 -------
534 result : `Timespan`
535 Newly-constructed object.
536 """
537 nsec1, nsec2 = simple # for mypy
538 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
540 to_json = to_json_generic
541 from_json = classmethod(from_json_generic)
543 @classmethod
544 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
545 """Convert Timespan into YAML format.
547 This produces a scalar node with a tag "!_SpecialTimespanBound" and
548 value being a name of _SpecialTimespanBound enum.
550 Parameters
551 ----------
552 dumper : `yaml.Dumper`
553 YAML dumper instance.
554 timespan : `Timespan`
555 Data to be converted.
556 """
557 if timespan.isEmpty():
558 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
559 else:
560 return dumper.represent_mapping(
561 cls.yaml_tag,
562 dict(begin=timespan.begin, end=timespan.end),
563 )
565 @classmethod
566 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.ScalarNode) -> Optional[Timespan]:
567 """Convert YAML node into _SpecialTimespanBound.
569 Parameters
570 ----------
571 loader : `yaml.SafeLoader`
572 Instance of YAML loader class.
573 node : `yaml.ScalarNode`
574 YAML node.
576 Returns
577 -------
578 value : `Timespan`
579 Timespan instance, can be ``None``.
580 """
581 if node.value is None:
582 return None
583 elif node.value == "EMPTY":
584 return Timespan.makeEmpty()
585 else:
586 d = loader.construct_mapping(node)
587 return Timespan(d["begin"], d["end"])
590# Register Timespan -> YAML conversion method with Dumper class
591yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
593# Register YAML -> Timespan conversion method with Loader, for our use case we
594# only need SafeLoader.
595yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)
598_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
601class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]):
602 """Representation of a time span within a database engine.
604 Provides an interface that encapsulates how timespans are represented in a
605 database engine.
607 Most of this class's interface is comprised of classmethods. Instances
608 can be constructed via the `fromSelectable` or `fromLiteral` methods as a
609 way to include timespan overlap operations in query JOIN or WHERE clauses.
611 Notes
612 -----
613 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
614 same interval definitions and edge-case behavior as the `Timespan` class.
615 They are also guaranteed to round-trip `Timespan` instances exactly.
616 """
618 NAME: ClassVar[str] = "timespan"
619 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL
621 Compound: ClassVar[Type[TimespanDatabaseRepresentation]]
622 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
623 uses two separate fields for the begin (inclusive) and end (exclusive)
624 endpoints.
626 This implementation should be compatible with any SQL database, and should
627 generally be used when a database-specific implementation is not available.
628 """
630 __slots__ = ()
632 @classmethod
633 @abstractmethod
634 def fromLiteral(cls: Type[_S], timespan: Timespan) -> _S:
635 """Construct a database timespan from a literal `Timespan` instance.
637 Parameters
638 ----------
639 timespan : `Timespan`
640 Literal timespan to convert.
642 Returns
643 -------
644 tsRepr : `TimespanDatabaseRepresentation`
645 A timespan expression object backed by `sqlalchemy.sql.literal`
646 column expressions.
647 """
648 raise NotImplementedError()
650 @abstractmethod
651 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
652 """Return a boolean SQLAlchemy expression for testing empty timespans.
654 Returns
655 -------
656 empty : `sqlalchemy.sql.ColumnElement`
657 A boolean SQLAlchemy expression object.
658 """
659 raise NotImplementedError()
661 @abstractmethod
662 def __lt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
663 """Return SQLAlchemy expression for testing less than.
665 Returns a SQLAlchemy expression representing a test for whether an
666 in-database timespan is strictly less than another timespan or a time
667 point.
669 Parameters
670 ----------
671 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
672 The timespan or time to relate to ``self``; either an instance of
673 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
674 a SQL column expression representing an `astropy.time.Time`.
676 Returns
677 -------
678 less : `sqlalchemy.sql.ColumnElement`
679 A boolean SQLAlchemy expression object.
681 Notes
682 -----
683 See `Timespan.__lt__` for edge-case behavior.
684 """
685 raise NotImplementedError()
687 @abstractmethod
688 def __gt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
689 """Return a SQLAlchemy expression for testing greater than.
691 Returns a SQLAlchemy expression representing a test for whether an
692 in-database timespan is strictly greater than another timespan or a
693 time point.
695 Parameters
696 ----------
697 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
698 The timespan or time to relate to ``self``; either an instance of
699 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
700 a SQL column expression representing an `astropy.time.Time`.
702 Returns
703 -------
704 greater : `sqlalchemy.sql.ColumnElement`
705 A boolean SQLAlchemy expression object.
707 Notes
708 -----
709 See `Timespan.__gt__` for edge-case behavior.
710 """
711 raise NotImplementedError()
713 @abstractmethod
714 def overlaps(self: _S, other: _S) -> sqlalchemy.sql.ColumnElement:
715 """Return a SQLAlchemy expression representing timespan overlaps.
717 Parameters
718 ----------
719 other : ``type(self)``
720 The timespan to overlap ``self`` with.
722 Returns
723 -------
724 overlap : `sqlalchemy.sql.ColumnElement`
725 A boolean SQLAlchemy expression object.
727 Notes
728 -----
729 See `Timespan.overlaps` for edge-case behavior.
730 """
731 raise NotImplementedError()
733 @abstractmethod
734 def contains(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement:
735 """Return a SQLAlchemy expression representing containment.
737 Returns a test for whether an in-database timespan contains another
738 timespan or a time point.
740 Parameters
741 ----------
742 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
743 The timespan or time to relate to ``self``; either an instance of
744 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
745 a SQL column expression representing an `astropy.time.Time`.
747 Returns
748 -------
749 contains : `sqlalchemy.sql.ColumnElement`
750 A boolean SQLAlchemy expression object.
752 Notes
753 -----
754 See `Timespan.contains` for edge-case behavior.
755 """
756 raise NotImplementedError()
758 @abstractmethod
759 def lower(self: _S) -> sqlalchemy.sql.ColumnElement:
760 """Return a SQLAlchemy expression representing a lower bound of a
761 timespan.
763 Returns
764 -------
765 lower : `sqlalchemy.sql.ColumnElement`
766 A SQLAlchemy expression for a lower bound.
768 Notes
769 -----
770 If database holds ``NULL`` for a timespan then the returned expression
771 should evaluate to 0. Main purpose of this and `upper` method is to use
772 them in generating SQL, in particular ORDER BY clause, to guarantee a
773 predictable ordering. It may potentially be used for transforming
774 boolean user expressions into SQL, but it will likely require extra
775 attention to ordering issues.
776 """
777 raise NotImplementedError()
779 @abstractmethod
780 def upper(self: _S) -> sqlalchemy.sql.ColumnElement:
781 """Return a SQLAlchemy expression representing an upper bound of a
782 timespan.
784 Returns
785 -------
786 upper : `sqlalchemy.sql.ColumnElement`
787 A SQLAlchemy expression for an upper bound.
789 Notes
790 -----
791 If database holds ``NULL`` for a timespan then the returned expression
792 should evaluate to 0. Also see notes for `lower` method.
793 """
794 raise NotImplementedError()
797class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
798 """Representation of a time span as two separate fields.
800 An implementation of `TimespanDatabaseRepresentation` that simply stores
801 the endpoints in two separate fields.
803 This type should generally be accessed via
804 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
805 via the `fromSelectable` and `fromLiteral` methods.
807 Parameters
808 ----------
809 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
810 Tuple of SQLAlchemy objects representing the lower (inclusive) and
811 upper (exclusive) bounds, as 64-bit integer columns containing
812 nanoseconds.
813 name : `str`, optional
814 Name for the logical column; a part of the name for multi-column
815 representations. Defaults to ``cls.NAME``.
817 Notes
818 -----
819 ``NULL`` timespans are represented by having both fields set to ``NULL``;
820 setting only one to ``NULL`` is considered a corrupted state that should
821 only be possible if this interface is circumvented. `Timespan` instances
822 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
823 are set to fields mapped to the minimum and maximum value constants used
824 by our integer-time mapping.
825 """
827 def __init__(self, nsec: Tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
828 self._nsec = nsec
829 self._name = name
831 __slots__ = ("_nsec", "_name")
833 @classmethod
834 def makeFieldSpecs(
835 cls, nullable: bool, name: Optional[str] = None, **kwargs: Any
836 ) -> Tuple[ddl.FieldSpec, ...]:
837 # Docstring inherited.
838 if name is None:
839 name = cls.NAME
840 return (
841 ddl.FieldSpec(
842 f"{name}_begin",
843 dtype=sqlalchemy.BigInteger,
844 nullable=nullable,
845 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
846 **kwargs,
847 ),
848 ddl.FieldSpec(
849 f"{name}_end",
850 dtype=sqlalchemy.BigInteger,
851 nullable=nullable,
852 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
853 **kwargs,
854 ),
855 )
857 @classmethod
858 def getFieldNames(cls, name: Optional[str] = None) -> Tuple[str, ...]:
859 # Docstring inherited.
860 if name is None:
861 name = cls.NAME
862 return (f"{name}_begin", f"{name}_end")
864 @classmethod
865 def update(
866 cls, extent: Optional[Timespan], name: Optional[str] = None, result: Optional[Dict[str, Any]] = None
867 ) -> Dict[str, Any]:
868 # Docstring inherited.
869 if name is None:
870 name = cls.NAME
871 if result is None:
872 result = {}
873 if extent is None:
874 begin_nsec = None
875 end_nsec = None
876 else:
877 begin_nsec = extent._nsec[0]
878 end_nsec = extent._nsec[1]
879 result[f"{name}_begin"] = begin_nsec
880 result[f"{name}_end"] = end_nsec
881 return result
883 @classmethod
884 def extract(cls, mapping: Mapping[str, Any], name: Optional[str] = None) -> Optional[Timespan]:
885 # Docstring inherited.
886 if name is None:
887 name = cls.NAME
888 begin_nsec = mapping[f"{name}_begin"]
889 end_nsec = mapping[f"{name}_end"]
890 if begin_nsec is None:
891 if end_nsec is not None:
892 raise RuntimeError(
893 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
894 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
895 )
896 return None
897 elif end_nsec is None:
898 raise RuntimeError(
899 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
900 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
901 )
902 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
904 @classmethod
905 def fromSelectable(
906 cls, selectable: sqlalchemy.sql.FromClause, name: Optional[str] = None
907 ) -> _CompoundTimespanDatabaseRepresentation:
908 # Docstring inherited.
909 if name is None:
910 name = cls.NAME
911 return cls(nsec=(selectable.columns[f"{name}_begin"], selectable.columns[f"{name}_end"]), name=name)
913 @classmethod
914 def fromLiteral(cls, timespan: Timespan) -> _CompoundTimespanDatabaseRepresentation:
915 # Docstring inherited.
916 return cls(
917 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
918 name=cls.NAME,
919 )
921 @property
922 def name(self) -> str:
923 # Docstring inherited.
924 return self._name
926 def isNull(self) -> sqlalchemy.sql.ColumnElement:
927 # Docstring inherited.
928 return self._nsec[0].is_(None)
930 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
931 # Docstring inherited.
932 return self._nsec[0] >= self._nsec[1]
934 def __lt__(
935 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
936 ) -> sqlalchemy.sql.ColumnElement:
937 # Docstring inherited.
938 # See comments in Timespan.__lt__ for why we use these exact
939 # expressions.
940 if isinstance(other, sqlalchemy.sql.ColumnElement):
941 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
942 else:
943 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
945 def __gt__(
946 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
947 ) -> sqlalchemy.sql.ColumnElement:
948 # Docstring inherited.
949 # See comments in Timespan.__gt__ for why we use these exact
950 # expressions.
951 if isinstance(other, sqlalchemy.sql.ColumnElement):
952 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
953 else:
954 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
956 def overlaps(self, other: _CompoundTimespanDatabaseRepresentation) -> sqlalchemy.sql.ColumnElement:
957 # Docstring inherited.
958 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
960 def contains(
961 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement]
962 ) -> sqlalchemy.sql.ColumnElement:
963 # Docstring inherited.
964 if isinstance(other, sqlalchemy.sql.ColumnElement):
965 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
966 else:
967 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
969 def lower(self) -> sqlalchemy.sql.ColumnElement:
970 # Docstring inherited.
971 return sqlalchemy.sql.functions.coalesce(self._nsec[0], sqlalchemy.sql.literal(0))
973 def upper(self) -> sqlalchemy.sql.ColumnElement:
974 # Docstring inherited.
975 return sqlalchemy.sql.functions.coalesce(self._nsec[1], sqlalchemy.sql.literal(0))
977 def flatten(self, name: Optional[str] = None) -> Iterator[sqlalchemy.sql.ColumnElement]:
978 # Docstring inherited.
979 if name is None:
980 yield from self._nsec
981 else:
982 yield self._nsec[0].label(f"{name}_begin")
983 yield self._nsec[1].label(f"{name}_end")
986TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation