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