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