Coverage for python/lsst/daf/butler/core/timespan.py: 44%
314 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-21 09:55 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-21 09:55 +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 ABC, abstractmethod
31from collections.abc import Generator, Mapping
32from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union
34import astropy.time
35import astropy.utils.exceptions
36import sqlalchemy
37import yaml
39# As of astropy 4.2, the erfa interface is shipped independently and
40# ErfaWarning is no longer an AstropyWarning
41try:
42 import erfa
43except ImportError:
44 erfa = None
46from lsst.utils.classes import cached_getter
48from . import ddl
49from .json import from_json_generic, to_json_generic
50from .time_utils import TimeConverter
52if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
53 from ..registry import Registry
54 from .dimensions import DimensionUniverse
57class _SpecialTimespanBound(enum.Enum):
58 """Enumeration to provide a singleton value for empty timespan bounds.
60 This enum's only member should generally be accessed via the
61 `Timespan.EMPTY` alias.
62 """
64 EMPTY = enum.auto()
65 """The value used for both `Timespan.begin` and `Timespan.end` for empty
66 Timespans that contain no points.
67 """
70TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
73class Timespan:
74 """A half-open time interval with nanosecond precision.
76 Parameters
77 ----------
78 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
79 Minimum timestamp in the interval (inclusive). `None` indicates that
80 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
81 timespan contains no times; if this is used as either bound, the other
82 bound is ignored.
83 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
84 Maximum timestamp in the interval (exclusive). `None` indicates that
85 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
86 creates an empty timespan.
87 padInstantaneous : `bool`, optional
88 If `True` (default) and ``begin == end`` *after discretization to
89 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
90 finite-duration timespan. If `False`, ``begin == end`` evaluates to
91 the empty timespan.
92 _nsec : `tuple` of `int`, optional
93 Integer nanosecond representation, for internal use by `Timespan` and
94 `TimespanDatabaseRepresentation` implementation only. If provided,
95 all other arguments are are ignored.
97 Raises
98 ------
99 TypeError
100 Raised if ``begin`` or ``end`` has a type other than
101 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
102 ValueError
103 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
104 supported by this class.
106 Notes
107 -----
108 Timespans are half-open intervals, i.e. ``[begin, end)``.
110 Any timespan with ``begin > end`` after nanosecond discretization
111 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
112 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
113 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
114 contained by all other timespans (including itself). It is also disjoint
115 with all timespans (including itself), and hence does not overlap any
116 timespan - this is the only case where ``contains`` does not imply
117 ``overlaps``.
119 Finite timespan bounds are represented internally as integer nanoseconds,
120 and hence construction from `astropy.time.Time` (which has picosecond
121 accuracy) can involve a loss of precision. This is of course
122 deterministic, so any `astropy.time.Time` value is always mapped
123 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
124 timespans that are empty at full precision (``begin > end``,
125 ``begin - end < 1ns``) may be finite after discretization. In all other
126 cases, the relationships between full-precision timespans should be
127 preserved even if the values are not.
129 The `astropy.time.Time` bounds that can be obtained after construction from
130 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
131 exactly when used to construct other `Timespan` instances.
132 """
134 def __init__(
135 self,
136 begin: TimespanBound,
137 end: TimespanBound,
138 padInstantaneous: bool = True,
139 _nsec: tuple[int, int] | None = None,
140 ):
141 converter = TimeConverter()
142 if _nsec is None:
143 begin_nsec: int
144 if begin is None:
145 begin_nsec = converter.min_nsec
146 elif begin is self.EMPTY:
147 begin_nsec = converter.max_nsec
148 elif isinstance(begin, astropy.time.Time):
149 begin_nsec = converter.astropy_to_nsec(begin)
150 else:
151 raise TypeError(
152 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
153 )
154 end_nsec: int
155 if end is None:
156 end_nsec = converter.max_nsec
157 elif end is self.EMPTY:
158 end_nsec = converter.min_nsec
159 elif isinstance(end, astropy.time.Time):
160 end_nsec = converter.astropy_to_nsec(end)
161 else:
162 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.")
163 if begin_nsec == end_nsec:
164 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
165 with warnings.catch_warnings():
166 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
167 if erfa is not None:
168 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
169 if begin is not None and begin < converter.epoch:
170 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
171 if end is not None and end > converter.max_time:
172 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
173 raise ValueError("Infinite instantaneous timespans are not supported.")
174 elif padInstantaneous:
175 end_nsec += 1
176 if end_nsec == converter.max_nsec:
177 raise ValueError(
178 f"Cannot construct near-instantaneous timespan at {end}; "
179 "within one ns of maximum time."
180 )
181 _nsec = (begin_nsec, end_nsec)
182 if _nsec[0] >= _nsec[1]:
183 # Standardizing all empty timespans to the same underlying values
184 # here simplifies all other operations (including interactions
185 # with TimespanDatabaseRepresentation implementations).
186 _nsec = (converter.max_nsec, converter.min_nsec)
187 self._nsec = _nsec
189 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
191 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
193 # YAML tag name for Timespan
194 yaml_tag = "!lsst.daf.butler.Timespan"
196 @classmethod
197 def makeEmpty(cls) -> Timespan:
198 """Construct an empty timespan.
200 Returns
201 -------
202 empty : `Timespan`
203 A timespan that is contained by all timespans (including itself)
204 and overlaps no other timespans (including itself).
205 """
206 converter = TimeConverter()
207 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
209 @classmethod
210 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
211 """Construct a timespan that approximates an instant in time.
213 This is done by constructing a minimum-possible (1 ns) duration
214 timespan.
216 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
217 but may be slightly more efficient.
219 Parameters
220 ----------
221 time : `astropy.time.Time`
222 Time to use for the lower bound.
224 Returns
225 -------
226 instant : `Timespan`
227 A ``[time, time + 1ns)`` timespan.
228 """
229 converter = TimeConverter()
230 nsec = converter.astropy_to_nsec(time)
231 if nsec == converter.max_nsec - 1:
232 raise ValueError(
233 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time."
234 )
235 return Timespan(None, None, _nsec=(nsec, nsec + 1))
237 @property
238 @cached_getter
239 def begin(self) -> TimespanBound:
240 """Minimum timestamp in the interval, inclusive.
242 If this bound is finite, this is an `astropy.time.Time` instance.
243 If the timespan is unbounded from below, this is `None`.
244 If the timespan is empty, this is the special value `Timespan.EMPTY`.
245 """
246 if self.isEmpty():
247 return self.EMPTY
248 elif self._nsec[0] == TimeConverter().min_nsec:
249 return None
250 else:
251 return TimeConverter().nsec_to_astropy(self._nsec[0])
253 @property
254 @cached_getter
255 def end(self) -> TimespanBound:
256 """Maximum timestamp in the interval, exclusive.
258 If this bound is finite, this is an `astropy.time.Time` instance.
259 If the timespan is unbounded from above, this is `None`.
260 If the timespan is empty, this is the special value `Timespan.EMPTY`.
261 """
262 if self.isEmpty():
263 return self.EMPTY
264 elif self._nsec[1] == TimeConverter().max_nsec:
265 return None
266 else:
267 return TimeConverter().nsec_to_astropy(self._nsec[1])
269 def isEmpty(self) -> bool:
270 """Test whether ``self`` is the empty timespan (`bool`)."""
271 return self._nsec[0] >= self._nsec[1]
273 def __str__(self) -> str:
274 if self.isEmpty():
275 return "(empty)"
276 # Trap dubious year warnings in case we have timespans from
277 # simulated data in the future
278 with warnings.catch_warnings():
279 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
280 if erfa is not None:
281 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
282 if self.begin is None:
283 head = "(-∞, "
284 else:
285 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
286 head = f"[{self.begin.tai.isot}, "
287 if self.end is None:
288 tail = "∞)"
289 else:
290 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
291 tail = f"{self.end.tai.isot})"
292 return head + tail
294 def __repr__(self) -> str:
295 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
296 # simulate our own here to make Timespan's __repr__ eval-friendly.
297 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
298 # eval-friendly __repr__.
299 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
300 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
301 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
302 return f"Timespan(begin={begin}, end={end})"
304 def __eq__(self, other: Any) -> bool:
305 if not isinstance(other, Timespan):
306 return False
307 # Correctness of this simple implementation depends on __init__
308 # standardizing all empty timespans to a single value.
309 return self._nsec == other._nsec
311 def __hash__(self) -> int:
312 # Correctness of this simple implementation depends on __init__
313 # standardizing all empty timespans to a single value.
314 return hash(self._nsec)
316 def __reduce__(self) -> tuple:
317 return (Timespan, (None, None, False, self._nsec))
319 def __lt__(self, other: astropy.time.Time | Timespan) -> bool:
320 """Test if a Timespan's bounds are strictly less than the given time.
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: astropy.time.Time | Timespan) -> bool:
347 """Test if a Timespan's bounds are strictly greater than given time.
349 Parameters
350 ----------
351 other : `Timespan` or `astropy.time.Time`.
352 Timespan or instant in time to relate to ``self``.
354 Returns
355 -------
356 greater : `bool`
357 The result of the greater-than test. `False` if either operand is
358 empty.
359 """
360 # First term in each expression below is the "normal" one; the second
361 # ensures correct behavior for empty timespans. It's important that
362 # the second uses a strict inequality to make sure inf == inf isn't in
363 # play, and it's okay for the second to use a strict inequality only
364 # because we know non-empty Timespans have nonzero duration, and hence
365 # the second term is never false for non-empty timespans unless the
366 # first term is also false.
367 if isinstance(other, astropy.time.Time):
368 nsec = TimeConverter().astropy_to_nsec(other)
369 return self._nsec[0] > nsec and self._nsec[1] > nsec
370 else:
371 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
373 def overlaps(self, other: Timespan | astropy.time.Time) -> bool:
374 """Test if the intersection of this Timespan with another is empty.
376 Parameters
377 ----------
378 other : `Timespan` or `astropy.time.Time`
379 Timespan or time to relate to ``self``. If a single time, this is
380 a synonym for `contains`.
382 Returns
383 -------
384 overlaps : `bool`
385 The result of the overlap test.
387 Notes
388 -----
389 If either ``self`` or ``other`` is empty, the result is always `False`.
390 In all other cases, ``self.contains(other)`` being `True` implies that
391 ``self.overlaps(other)`` is also `True`.
392 """
393 if isinstance(other, astropy.time.Time):
394 return self.contains(other)
395 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
397 def contains(self, other: astropy.time.Time | Timespan) -> bool:
398 """Test if the supplied timespan is within this one.
400 Tests whether the intersection of this timespan with another timespan
401 or point is equal to the other one.
403 Parameters
404 ----------
405 other : `Timespan` or `astropy.time.Time`.
406 Timespan or instant in time to relate to ``self``.
408 Returns
409 -------
410 overlaps : `bool`
411 The result of the contains test.
413 Notes
414 -----
415 If ``other`` is empty, `True` is always returned. In all other cases,
416 ``self.contains(other)`` being `True` implies that
417 ``self.overlaps(other)`` is also `True`.
419 Testing whether an instantaneous `astropy.time.Time` value is contained
420 in a timespan is not equivalent to testing a timespan constructed via
421 `Timespan.fromInstant`, because Timespan cannot exactly represent
422 zero-duration intervals. In particular, ``[a, b)`` contains the time
423 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
424 by `Timespan.fromInstant(b)``.
425 """
426 if isinstance(other, astropy.time.Time):
427 nsec = TimeConverter().astropy_to_nsec(other)
428 return self._nsec[0] <= nsec and self._nsec[1] > nsec
429 else:
430 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
432 def intersection(self, *args: Timespan) -> Timespan:
433 """Return a new `Timespan` that is contained by all of the given ones.
435 Parameters
436 ----------
437 *args
438 All positional arguments are `Timespan` instances.
440 Returns
441 -------
442 intersection : `Timespan`
443 The intersection timespan.
444 """
445 if not args:
446 return self
447 lowers = [self._nsec[0]]
448 lowers.extend(ts._nsec[0] for ts in args)
449 uppers = [self._nsec[1]]
450 uppers.extend(ts._nsec[1] for ts in args)
451 nsec = (max(*lowers), min(*uppers))
452 return Timespan(begin=None, end=None, _nsec=nsec)
454 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
455 """Return the one or two timespans that cover the interval(s).
457 The interval is defined as one that is in ``self`` but not ``other``.
459 This is implemented as a generator because the result may be zero, one,
460 or two `Timespan` objects, depending on the relationship between the
461 operands.
463 Parameters
464 ----------
465 other : `Timespan`
466 Timespan to subtract.
468 Yields
469 ------
470 result : `Timespan`
471 A `Timespan` that is contained by ``self`` but does not overlap
472 ``other``. Guaranteed not to be empty.
473 """
474 intersection = self.intersection(other)
475 if intersection.isEmpty():
476 yield self
477 elif intersection == self:
478 yield from ()
479 else:
480 if intersection._nsec[0] > self._nsec[0]:
481 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
482 if intersection._nsec[1] < self._nsec[1]:
483 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
485 def to_simple(self, minimal: bool = False) -> list[int]:
486 """Return simple python type form suitable for serialization.
488 Parameters
489 ----------
490 minimal : `bool`, optional
491 Use minimal serialization. Has no effect on for this class.
493 Returns
494 -------
495 simple : `list` of `int`
496 The internal span as integer nanoseconds.
497 """
498 # Return the internal nanosecond form rather than astropy ISO string
499 return list(self._nsec)
501 @classmethod
502 def from_simple(
503 cls,
504 simple: list[int],
505 universe: DimensionUniverse | None = None,
506 registry: Registry | None = None,
507 ) -> Timespan:
508 """Construct a new object from simplified form.
510 Designed to use the data returned from the `to_simple` method.
512 Parameters
513 ----------
514 simple : `list` of `int`
515 The values returned by `to_simple()`.
516 universe : `DimensionUniverse`, optional
517 Unused.
518 registry : `lsst.daf.butler.Registry`, optional
519 Unused.
521 Returns
522 -------
523 result : `Timespan`
524 Newly-constructed object.
525 """
526 nsec1, nsec2 = simple # for mypy
527 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
529 to_json = to_json_generic
530 from_json: ClassVar = classmethod(from_json_generic)
532 @classmethod
533 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
534 """Convert Timespan into YAML format.
536 This produces a scalar node with a tag "!_SpecialTimespanBound" and
537 value being a name of _SpecialTimespanBound enum.
539 Parameters
540 ----------
541 dumper : `yaml.Dumper`
542 YAML dumper instance.
543 timespan : `Timespan`
544 Data to be converted.
545 """
546 if timespan.isEmpty():
547 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
548 else:
549 return dumper.represent_mapping(
550 cls.yaml_tag,
551 dict(begin=timespan.begin, end=timespan.end),
552 )
554 @classmethod
555 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None:
556 """Convert YAML node into _SpecialTimespanBound.
558 Parameters
559 ----------
560 loader : `yaml.SafeLoader`
561 Instance of YAML loader class.
562 node : `yaml.ScalarNode`
563 YAML node.
565 Returns
566 -------
567 value : `Timespan`
568 Timespan instance, can be ``None``.
569 """
570 if node.value is None:
571 return None
572 elif node.value == "EMPTY":
573 return Timespan.makeEmpty()
574 else:
575 d = loader.construct_mapping(node)
576 return Timespan(d["begin"], d["end"])
579# Register Timespan -> YAML conversion method with Dumper class
580yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
582# Register YAML -> Timespan conversion method with Loader, for our use case we
583# only need SafeLoader.
584yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)
587_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
590class TimespanDatabaseRepresentation(ABC):
591 """An interface for representing a timespan in a database.
593 Notes
594 -----
595 Much of this class's interface is comprised of classmethods. Instances
596 can be constructed via the `from_columns` or `fromLiteral` methods as a
597 way to include timespan overlap operations in query JOIN or WHERE clauses.
599 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
600 same interval definitions and edge-case behavior as the `Timespan` class.
601 They are also guaranteed to round-trip `Timespan` instances exactly.
602 """
604 NAME: ClassVar[str] = "timespan"
606 Compound: ClassVar[type[TimespanDatabaseRepresentation]]
607 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
608 uses two separate fields for the begin (inclusive) and end (exclusive)
609 endpoints.
611 This implementation should be compatible with any SQL database, and should
612 generally be used when a database-specific implementation is not available.
613 """
615 __slots__ = ()
617 @classmethod
618 @abstractmethod
619 def makeFieldSpecs(
620 cls, nullable: bool, name: str | None = None, **kwargs: Any
621 ) -> tuple[ddl.FieldSpec, ...]:
622 """Make objects that reflect the fields that must be added to table.
624 Makes one or more `ddl.FieldSpec` objects that reflect the fields
625 that must be added to a table for this representation.
627 Parameters
628 ----------
629 nullable : `bool`
630 If `True`, the timespan is permitted to be logically ``NULL``
631 (mapped to `None` in Python), though the corresponding value(s) in
632 the database are implementation-defined. Nullable timespan fields
633 default to NULL, while others default to (-∞, ∞).
634 name : `str`, optional
635 Name for the logical column; a part of the name for multi-column
636 representations. Defaults to ``cls.NAME``.
637 **kwargs
638 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
639 for all fields; implementations only provide the ``name``,
640 ``dtype``, and ``default`` arguments themselves.
642 Returns
643 -------
644 specs : `tuple` [ `ddl.FieldSpec` ]
645 Field specification objects; length of the tuple is
646 subclass-dependent, but is guaranteed to match the length of the
647 return values of `getFieldNames` and `update`.
648 """
649 raise NotImplementedError()
651 @classmethod
652 @abstractmethod
653 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
654 """Return the actual field names used by this representation.
656 Parameters
657 ----------
658 name : `str`, optional
659 Name for the logical column; a part of the name for multi-column
660 representations. Defaults to ``cls.NAME``.
662 Returns
663 -------
664 names : `tuple` [ `str` ]
665 Field name(s). Guaranteed to be the same as the names of the field
666 specifications returned by `makeFieldSpecs`.
667 """
668 raise NotImplementedError()
670 @classmethod
671 @abstractmethod
672 def fromLiteral(cls: type[_S], timespan: Timespan | None) -> _S:
673 """Construct a database timespan from a literal `Timespan` instance.
675 Parameters
676 ----------
677 timespan : `Timespan` or `None`
678 Literal timespan to convert, or `None` to make logically ``NULL``
679 timespan.
681 Returns
682 -------
683 tsRepr : `TimespanDatabaseRepresentation`
684 A timespan expression object backed by `sqlalchemy.sql.literal`
685 column expressions.
686 """
687 raise NotImplementedError()
689 @classmethod
690 @abstractmethod
691 def from_columns(cls: type[_S], columns: sqlalchemy.sql.ColumnCollection, name: str | None = None) -> _S:
692 """Construct a database timespan from the columns of a table or
693 subquery.
695 Parameters
696 ----------
697 columns : `sqlalchemy.sql.ColumnCollections`
698 SQLAlchemy container for raw columns.
699 name : `str`, optional
700 Name for the logical column; a part of the name for multi-column
701 representations. Defaults to ``cls.NAME``.
703 Returns
704 -------
705 tsRepr : `TimespanDatabaseRepresentation`
706 A timespan expression object backed by `sqlalchemy.sql.literal`
707 column expressions.
708 """
709 raise NotImplementedError()
711 @classmethod
712 @abstractmethod
713 def update(
714 cls, timespan: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
715 ) -> dict[str, Any]:
716 """Add a timespan value to a dictionary that represents a database row.
718 Parameters
719 ----------
720 timespan
721 A timespan literal, or `None` for ``NULL``.
722 name : `str`, optional
723 Name for the logical column; a part of the name for multi-column
724 representations. Defaults to ``cls.NAME``.
725 result : `dict` [ `str`, `Any` ], optional
726 A dictionary representing a database row that fields should be
727 added to, or `None` to create and return a new one.
729 Returns
730 -------
731 result : `dict` [ `str`, `Any` ]
732 A dictionary containing this representation of a timespan. Exactly
733 the `dict` passed as ``result`` if that is not `None`.
734 """
735 raise NotImplementedError()
737 @classmethod
738 @abstractmethod
739 def extract(cls, mapping: Mapping[Any, Any], name: str | None = None) -> Timespan | None:
740 """Extract a timespan from a dictionary that represents a database row.
742 Parameters
743 ----------
744 mapping : `~collections.abc.Mapping` [ `Any`, `Any` ]
745 A dictionary representing a database row containing a `Timespan`
746 in this representation. Should have key(s) equal to the return
747 value of `getFieldNames`.
748 name : `str`, optional
749 Name for the logical column; a part of the name for multi-column
750 representations. Defaults to ``cls.NAME``.
752 Returns
753 -------
754 timespan : `Timespan` or `None`
755 Python representation of the timespan.
756 """
757 raise NotImplementedError()
759 @classmethod
760 def hasExclusionConstraint(cls) -> bool:
761 """Return `True` if this representation supports exclusion constraints.
763 Returns
764 -------
765 supported : `bool`
766 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
767 includes the fields of this representation is allowed.
768 """
769 return False
771 @property
772 @abstractmethod
773 def name(self) -> str:
774 """Return base logical name for the timespan column or expression
775 (`str`).
777 If the representation uses only one actual column, this should be the
778 full name of the column. In other cases it is an unspecified
779 common subset of the column names.
780 """
781 raise NotImplementedError()
783 @abstractmethod
784 def isNull(self) -> sqlalchemy.sql.ColumnElement:
785 """Return expression that tests whether the timespan is ``NULL``.
787 Returns a SQLAlchemy expression that tests whether this region is
788 logically ``NULL``.
790 Returns
791 -------
792 isnull : `sqlalchemy.sql.ColumnElement`
793 A boolean SQLAlchemy expression object.
794 """
795 raise NotImplementedError()
797 @abstractmethod
798 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
799 """Return the actual column(s) that comprise this logical column.
801 Parameters
802 ----------
803 name : `str`, optional
804 If provided, a name for the logical column that should be used to
805 label the columns. If not provided, the columns' native names will
806 be used.
808 Returns
809 -------
810 columns : `tuple` [ `sqlalchemy.sql.ColumnElement` ]
811 The true column or columns that back this object.
812 """
813 raise NotImplementedError()
815 @abstractmethod
816 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
817 """Return a boolean SQLAlchemy expression for testing empty timespans.
819 Returns
820 -------
821 empty : `sqlalchemy.sql.ColumnElement`
822 A boolean SQLAlchemy expression object.
823 """
824 raise NotImplementedError()
826 @abstractmethod
827 def __lt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
828 """Return SQLAlchemy expression for testing less than.
830 Returns a SQLAlchemy expression representing a test for whether an
831 in-database timespan is strictly less than another timespan or a time
832 point.
834 Parameters
835 ----------
836 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
837 The timespan or time to relate to ``self``; either an instance of
838 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
839 a SQL column expression representing an `astropy.time.Time`.
841 Returns
842 -------
843 less : `sqlalchemy.sql.ColumnElement`
844 A boolean SQLAlchemy expression object.
846 Notes
847 -----
848 See `Timespan.__lt__` for edge-case behavior.
849 """
850 raise NotImplementedError()
852 @abstractmethod
853 def __gt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
854 """Return a SQLAlchemy expression for testing greater than.
856 Returns a SQLAlchemy expression representing a test for whether an
857 in-database timespan is strictly greater than another timespan or a
858 time point.
860 Parameters
861 ----------
862 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
863 The timespan or time to relate to ``self``; either an instance of
864 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
865 a SQL column expression representing an `astropy.time.Time`.
867 Returns
868 -------
869 greater : `sqlalchemy.sql.ColumnElement`
870 A boolean SQLAlchemy expression object.
872 Notes
873 -----
874 See `Timespan.__gt__` for edge-case behavior.
875 """
876 raise NotImplementedError()
878 @abstractmethod
879 def overlaps(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
880 """Return a SQLAlchemy expression representing timespan overlaps.
882 Parameters
883 ----------
884 other : ``type(self)``
885 The timespan or time to overlap ``self`` with. If a single time,
886 this is a synonym for `contains`.
888 Returns
889 -------
890 overlap : `sqlalchemy.sql.ColumnElement`
891 A boolean SQLAlchemy expression object.
893 Notes
894 -----
895 See `Timespan.overlaps` for edge-case behavior.
896 """
897 raise NotImplementedError()
899 @abstractmethod
900 def contains(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
901 """Return a SQLAlchemy expression representing containment.
903 Returns a test for whether an in-database timespan contains another
904 timespan or a time point.
906 Parameters
907 ----------
908 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
909 The timespan or time to relate to ``self``; either an instance of
910 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
911 a SQL column expression representing an `astropy.time.Time`.
913 Returns
914 -------
915 contains : `sqlalchemy.sql.ColumnElement`
916 A boolean SQLAlchemy expression object.
918 Notes
919 -----
920 See `Timespan.contains` for edge-case behavior.
921 """
922 raise NotImplementedError()
924 @abstractmethod
925 def lower(self: _S) -> sqlalchemy.sql.ColumnElement:
926 """Return a SQLAlchemy expression representing a lower bound of a
927 timespan.
929 Returns
930 -------
931 lower : `sqlalchemy.sql.ColumnElement`
932 A SQLAlchemy expression for a lower bound.
934 Notes
935 -----
936 If database holds ``NULL`` for a timespan then the returned expression
937 should evaluate to 0. Main purpose of this and `upper` method is to use
938 them in generating SQL, in particular ORDER BY clause, to guarantee a
939 predictable ordering. It may potentially be used for transforming
940 boolean user expressions into SQL, but it will likely require extra
941 attention to ordering issues.
942 """
943 raise NotImplementedError()
945 @abstractmethod
946 def upper(self: _S) -> sqlalchemy.sql.ColumnElement:
947 """Return a SQLAlchemy expression representing an upper bound of a
948 timespan.
950 Returns
951 -------
952 upper : `sqlalchemy.sql.ColumnElement`
953 A SQLAlchemy expression for an upper bound.
955 Notes
956 -----
957 If database holds ``NULL`` for a timespan then the returned expression
958 should evaluate to 0. Also see notes for `lower` method.
959 """
960 raise NotImplementedError()
963class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
964 """Representation of a time span as two separate fields.
966 An implementation of `TimespanDatabaseRepresentation` that simply stores
967 the endpoints in two separate fields.
969 This type should generally be accessed via
970 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
971 via the `from_columns` and `fromLiteral` methods.
973 Parameters
974 ----------
975 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
976 Tuple of SQLAlchemy objects representing the lower (inclusive) and
977 upper (exclusive) bounds, as 64-bit integer columns containing
978 nanoseconds.
979 name : `str`, optional
980 Name for the logical column; a part of the name for multi-column
981 representations. Defaults to ``cls.NAME``.
983 Notes
984 -----
985 ``NULL`` timespans are represented by having both fields set to ``NULL``;
986 setting only one to ``NULL`` is considered a corrupted state that should
987 only be possible if this interface is circumvented. `Timespan` instances
988 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
989 are set to fields mapped to the minimum and maximum value constants used
990 by our integer-time mapping.
991 """
993 def __init__(self, nsec: tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
994 self._nsec = nsec
995 self._name = name
997 __slots__ = ("_nsec", "_name")
999 @classmethod
1000 def makeFieldSpecs(
1001 cls, nullable: bool, name: str | None = None, **kwargs: Any
1002 ) -> tuple[ddl.FieldSpec, ...]:
1003 # Docstring inherited.
1004 if name is None:
1005 name = cls.NAME
1006 return (
1007 ddl.FieldSpec(
1008 f"{name}_begin",
1009 dtype=sqlalchemy.BigInteger,
1010 nullable=nullable,
1011 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
1012 **kwargs,
1013 ),
1014 ddl.FieldSpec(
1015 f"{name}_end",
1016 dtype=sqlalchemy.BigInteger,
1017 nullable=nullable,
1018 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
1019 **kwargs,
1020 ),
1021 )
1023 @classmethod
1024 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
1025 # Docstring inherited.
1026 if name is None:
1027 name = cls.NAME
1028 return (f"{name}_begin", f"{name}_end")
1030 @classmethod
1031 def update(
1032 cls, extent: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
1033 ) -> dict[str, Any]:
1034 # Docstring inherited.
1035 if name is None:
1036 name = cls.NAME
1037 if result is None:
1038 result = {}
1039 if extent is None:
1040 begin_nsec = None
1041 end_nsec = None
1042 else:
1043 begin_nsec = extent._nsec[0]
1044 end_nsec = extent._nsec[1]
1045 result[f"{name}_begin"] = begin_nsec
1046 result[f"{name}_end"] = end_nsec
1047 return result
1049 @classmethod
1050 def extract(cls, mapping: Mapping[str, Any], name: str | None = None) -> Timespan | None:
1051 # Docstring inherited.
1052 if name is None:
1053 name = cls.NAME
1054 begin_nsec = mapping[f"{name}_begin"]
1055 end_nsec = mapping[f"{name}_end"]
1056 if begin_nsec is None:
1057 if end_nsec is not None:
1058 raise RuntimeError(
1059 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
1060 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
1061 )
1062 return None
1063 elif end_nsec is None:
1064 raise RuntimeError(
1065 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
1066 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
1067 )
1068 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
1070 @classmethod
1071 def from_columns(
1072 cls, columns: sqlalchemy.sql.ColumnCollection, name: str | None = None
1073 ) -> _CompoundTimespanDatabaseRepresentation:
1074 # Docstring inherited.
1075 if name is None:
1076 name = cls.NAME
1077 return cls(nsec=(columns[f"{name}_begin"], columns[f"{name}_end"]), name=name)
1079 @classmethod
1080 def fromLiteral(cls, timespan: Timespan | None) -> _CompoundTimespanDatabaseRepresentation:
1081 # Docstring inherited.
1082 if timespan is None:
1083 return cls(nsec=(sqlalchemy.sql.null(), sqlalchemy.sql.null()), name=cls.NAME)
1084 return cls(
1085 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
1086 name=cls.NAME,
1087 )
1089 @property
1090 def name(self) -> str:
1091 # Docstring inherited.
1092 return self._name
1094 def isNull(self) -> sqlalchemy.sql.ColumnElement:
1095 # Docstring inherited.
1096 return self._nsec[0].is_(None)
1098 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
1099 # Docstring inherited.
1100 return self._nsec[0] >= self._nsec[1]
1102 def __lt__(
1103 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1104 ) -> sqlalchemy.sql.ColumnElement:
1105 # Docstring inherited.
1106 # See comments in Timespan.__lt__ for why we use these exact
1107 # expressions.
1108 if isinstance(other, sqlalchemy.sql.ColumnElement):
1109 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
1110 else:
1111 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
1113 def __gt__(
1114 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1115 ) -> sqlalchemy.sql.ColumnElement:
1116 # Docstring inherited.
1117 # See comments in Timespan.__gt__ for why we use these exact
1118 # expressions.
1119 if isinstance(other, sqlalchemy.sql.ColumnElement):
1120 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
1121 else:
1122 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
1124 def overlaps(
1125 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1126 ) -> sqlalchemy.sql.ColumnElement:
1127 # Docstring inherited.
1128 if isinstance(other, sqlalchemy.sql.ColumnElement):
1129 return self.contains(other)
1130 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
1132 def contains(
1133 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1134 ) -> sqlalchemy.sql.ColumnElement:
1135 # Docstring inherited.
1136 if isinstance(other, sqlalchemy.sql.ColumnElement):
1137 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
1138 else:
1139 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
1141 def lower(self) -> sqlalchemy.sql.ColumnElement:
1142 # Docstring inherited.
1143 return sqlalchemy.sql.functions.coalesce(self._nsec[0], sqlalchemy.sql.literal(0))
1145 def upper(self) -> sqlalchemy.sql.ColumnElement:
1146 # Docstring inherited.
1147 return sqlalchemy.sql.functions.coalesce(self._nsec[1], sqlalchemy.sql.literal(0))
1149 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
1150 # Docstring inherited.
1151 if name is None:
1152 return self._nsec
1153 else:
1154 return (
1155 self._nsec[0].label(f"{name}_begin"),
1156 self._nsec[1].label(f"{name}_end"),
1157 )
1160TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation