Coverage for python/lsst/daf/butler/_timespan.py: 44%
314 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-27 09:44 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-27 09:44 +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 software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
27from __future__ import annotations
29__all__ = (
30 "Timespan",
31 "TimespanDatabaseRepresentation",
32)
34import enum
35import warnings
36from abc import ABC, abstractmethod
37from collections.abc import Generator, Mapping
38from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union
40import astropy.time
41import astropy.utils.exceptions
42import sqlalchemy
43import yaml
45# As of astropy 4.2, the erfa interface is shipped independently and
46# ErfaWarning is no longer an AstropyWarning
47try:
48 import erfa
49except ImportError:
50 erfa = None
52from lsst.utils.classes import cached_getter
54from . import ddl
55from .json import from_json_generic, to_json_generic
56from .time_utils import TimeConverter
58if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
59 from .dimensions import DimensionUniverse
60 from .registry import Registry
63class _SpecialTimespanBound(enum.Enum):
64 """Enumeration to provide a singleton value for empty timespan bounds.
66 This enum's only member should generally be accessed via the
67 `Timespan.EMPTY` alias.
68 """
70 EMPTY = enum.auto()
71 """The value used for both `Timespan.begin` and `Timespan.end` for empty
72 Timespans that contain no points.
73 """
76TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None]
79class Timespan:
80 """A half-open time interval with nanosecond precision.
82 Parameters
83 ----------
84 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None`
85 Minimum timestamp in the interval (inclusive). `None` indicates that
86 the timespan has no lower bound. `Timespan.EMPTY` indicates that the
87 timespan contains no times; if this is used as either bound, the other
88 bound is ignored.
89 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None`
90 Maximum timestamp in the interval (exclusive). `None` indicates that
91 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY`
92 creates an empty timespan.
93 padInstantaneous : `bool`, optional
94 If `True` (default) and ``begin == end`` *after discretization to
95 integer nanoseconds*, extend ``end`` by one nanosecond to yield a
96 finite-duration timespan. If `False`, ``begin == end`` evaluates to
97 the empty timespan.
98 _nsec : `tuple` of `int`, optional
99 Integer nanosecond representation, for internal use by `Timespan` and
100 `TimespanDatabaseRepresentation` implementation only. If provided,
101 all other arguments are are ignored.
103 Raises
104 ------
105 TypeError
106 Raised if ``begin`` or ``end`` has a type other than
107 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`.
108 ValueError
109 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times
110 supported by this class.
112 Notes
113 -----
114 Timespans are half-open intervals, i.e. ``[begin, end)``.
116 Any timespan with ``begin > end`` after nanosecond discretization
117 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound
118 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both
119 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and
120 contained by all other timespans (including itself). It is also disjoint
121 with all timespans (including itself), and hence does not overlap any
122 timespan - this is the only case where ``contains`` does not imply
123 ``overlaps``.
125 Finite timespan bounds are represented internally as integer nanoseconds,
126 and hence construction from `astropy.time.Time` (which has picosecond
127 accuracy) can involve a loss of precision. This is of course
128 deterministic, so any `astropy.time.Time` value is always mapped
129 to the exact same timespan bound, but if ``padInstantaneous`` is `True`,
130 timespans that are empty at full precision (``begin > end``,
131 ``begin - end < 1ns``) may be finite after discretization. In all other
132 cases, the relationships between full-precision timespans should be
133 preserved even if the values are not.
135 The `astropy.time.Time` bounds that can be obtained after construction from
136 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip
137 exactly when used to construct other `Timespan` instances.
138 """
140 def __init__(
141 self,
142 begin: TimespanBound,
143 end: TimespanBound,
144 padInstantaneous: bool = True,
145 _nsec: tuple[int, int] | None = None,
146 ):
147 converter = TimeConverter()
148 if _nsec is None:
149 begin_nsec: int
150 if begin is None:
151 begin_nsec = converter.min_nsec
152 elif begin is self.EMPTY:
153 begin_nsec = converter.max_nsec
154 elif isinstance(begin, astropy.time.Time):
155 begin_nsec = converter.astropy_to_nsec(begin)
156 else:
157 raise TypeError(
158 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}."
159 )
160 end_nsec: int
161 if end is None:
162 end_nsec = converter.max_nsec
163 elif end is self.EMPTY:
164 end_nsec = converter.min_nsec
165 elif isinstance(end, astropy.time.Time):
166 end_nsec = converter.astropy_to_nsec(end)
167 else:
168 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.")
169 if begin_nsec == end_nsec:
170 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec:
171 with warnings.catch_warnings():
172 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
173 if erfa is not None:
174 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
175 if begin is not None and begin < converter.epoch:
176 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.")
177 if end is not None and end > converter.max_time:
178 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.")
179 raise ValueError("Infinite instantaneous timespans are not supported.")
180 elif padInstantaneous:
181 end_nsec += 1
182 if end_nsec == converter.max_nsec:
183 raise ValueError(
184 f"Cannot construct near-instantaneous timespan at {end}; "
185 "within one ns of maximum time."
186 )
187 _nsec = (begin_nsec, end_nsec)
188 if _nsec[0] >= _nsec[1]:
189 # Standardizing all empty timespans to the same underlying values
190 # here simplifies all other operations (including interactions
191 # with TimespanDatabaseRepresentation implementations).
192 _nsec = (converter.max_nsec, converter.min_nsec)
193 self._nsec = _nsec
195 __slots__ = ("_nsec", "_cached_begin", "_cached_end")
197 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY
199 # YAML tag name for Timespan
200 yaml_tag = "!lsst.daf.butler.Timespan"
202 @classmethod
203 def makeEmpty(cls) -> Timespan:
204 """Construct an empty timespan.
206 Returns
207 -------
208 empty : `Timespan`
209 A timespan that is contained by all timespans (including itself)
210 and overlaps no other timespans (including itself).
211 """
212 converter = TimeConverter()
213 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec))
215 @classmethod
216 def fromInstant(cls, time: astropy.time.Time) -> Timespan:
217 """Construct a timespan that approximates an instant in time.
219 This is done by constructing a minimum-possible (1 ns) duration
220 timespan.
222 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``,
223 but may be slightly more efficient.
225 Parameters
226 ----------
227 time : `astropy.time.Time`
228 Time to use for the lower bound.
230 Returns
231 -------
232 instant : `Timespan`
233 A ``[time, time + 1ns)`` timespan.
234 """
235 converter = TimeConverter()
236 nsec = converter.astropy_to_nsec(time)
237 if nsec == converter.max_nsec - 1:
238 raise ValueError(
239 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time."
240 )
241 return Timespan(None, None, _nsec=(nsec, nsec + 1))
243 @property
244 @cached_getter
245 def begin(self) -> TimespanBound:
246 """Minimum timestamp in the interval, inclusive.
248 If this bound is finite, this is an `astropy.time.Time` instance.
249 If the timespan is unbounded from below, this is `None`.
250 If the timespan is empty, this is the special value `Timespan.EMPTY`.
251 """
252 if self.isEmpty():
253 return self.EMPTY
254 elif self._nsec[0] == TimeConverter().min_nsec:
255 return None
256 else:
257 return TimeConverter().nsec_to_astropy(self._nsec[0])
259 @property
260 @cached_getter
261 def end(self) -> TimespanBound:
262 """Maximum timestamp in the interval, exclusive.
264 If this bound is finite, this is an `astropy.time.Time` instance.
265 If the timespan is unbounded from above, this is `None`.
266 If the timespan is empty, this is the special value `Timespan.EMPTY`.
267 """
268 if self.isEmpty():
269 return self.EMPTY
270 elif self._nsec[1] == TimeConverter().max_nsec:
271 return None
272 else:
273 return TimeConverter().nsec_to_astropy(self._nsec[1])
275 def isEmpty(self) -> bool:
276 """Test whether ``self`` is the empty timespan (`bool`)."""
277 return self._nsec[0] >= self._nsec[1]
279 def __str__(self) -> str:
280 if self.isEmpty():
281 return "(empty)"
282 # Trap dubious year warnings in case we have timespans from
283 # simulated data in the future
284 with warnings.catch_warnings():
285 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
286 if erfa is not None:
287 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
288 if self.begin is None:
289 head = "(-∞, "
290 else:
291 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
292 head = f"[{self.begin.tai.isot}, "
293 if self.end is None:
294 tail = "∞)"
295 else:
296 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
297 tail = f"{self.end.tai.isot})"
298 return head + tail
300 def __repr__(self) -> str:
301 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
302 # simulate our own here to make Timespan's __repr__ eval-friendly.
303 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
304 # eval-friendly __repr__.
305 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
306 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
307 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
308 return f"Timespan(begin={begin}, end={end})"
310 def __eq__(self, other: Any) -> bool:
311 if not isinstance(other, Timespan):
312 return False
313 # Correctness of this simple implementation depends on __init__
314 # standardizing all empty timespans to a single value.
315 return self._nsec == other._nsec
317 def __hash__(self) -> int:
318 # Correctness of this simple implementation depends on __init__
319 # standardizing all empty timespans to a single value.
320 return hash(self._nsec)
322 def __reduce__(self) -> tuple:
323 return (Timespan, (None, None, False, self._nsec))
325 def __lt__(self, other: astropy.time.Time | Timespan) -> bool:
326 """Test if a Timespan's bounds are strictly less than the given time.
328 Parameters
329 ----------
330 other : `Timespan` or `astropy.time.Time`.
331 Timespan or instant in time to relate to ``self``.
333 Returns
334 -------
335 less : `bool`
336 The result of the less-than test. `False` if either operand is
337 empty.
338 """
339 # First term in each expression below is the "normal" one; the second
340 # ensures correct behavior for empty timespans. It's important that
341 # the second uses a strict inequality to make sure inf == inf isn't in
342 # play, and it's okay for the second to use a strict inequality only
343 # because we know non-empty Timespans have nonzero duration, and hence
344 # the second term is never false for non-empty timespans unless the
345 # first term is also false.
346 if isinstance(other, astropy.time.Time):
347 nsec = TimeConverter().astropy_to_nsec(other)
348 return self._nsec[1] <= nsec and self._nsec[0] < nsec
349 else:
350 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
352 def __gt__(self, other: astropy.time.Time | Timespan) -> bool:
353 """Test if a Timespan's bounds are strictly greater than given time.
355 Parameters
356 ----------
357 other : `Timespan` or `astropy.time.Time`.
358 Timespan or instant in time to relate to ``self``.
360 Returns
361 -------
362 greater : `bool`
363 The result of the greater-than test. `False` if either operand is
364 empty.
365 """
366 # First term in each expression below is the "normal" one; the second
367 # ensures correct behavior for empty timespans. It's important that
368 # the second uses a strict inequality to make sure inf == inf isn't in
369 # play, and it's okay for the second to use a strict inequality only
370 # because we know non-empty Timespans have nonzero duration, and hence
371 # the second term is never false for non-empty timespans unless the
372 # first term is also false.
373 if isinstance(other, astropy.time.Time):
374 nsec = TimeConverter().astropy_to_nsec(other)
375 return self._nsec[0] > nsec and self._nsec[1] > nsec
376 else:
377 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
379 def overlaps(self, other: Timespan | astropy.time.Time) -> bool:
380 """Test if the intersection of this Timespan with another is empty.
382 Parameters
383 ----------
384 other : `Timespan` or `astropy.time.Time`
385 Timespan or time to relate to ``self``. If a single time, this is
386 a synonym for `contains`.
388 Returns
389 -------
390 overlaps : `bool`
391 The result of the overlap test.
393 Notes
394 -----
395 If either ``self`` or ``other`` is empty, the result is always `False`.
396 In all other cases, ``self.contains(other)`` being `True` implies that
397 ``self.overlaps(other)`` is also `True`.
398 """
399 if isinstance(other, astropy.time.Time):
400 return self.contains(other)
401 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
403 def contains(self, other: astropy.time.Time | Timespan) -> bool:
404 """Test if the supplied timespan is within this one.
406 Tests whether the intersection of this timespan with another timespan
407 or point is equal to the other one.
409 Parameters
410 ----------
411 other : `Timespan` or `astropy.time.Time`.
412 Timespan or instant in time to relate to ``self``.
414 Returns
415 -------
416 overlaps : `bool`
417 The result of the contains test.
419 Notes
420 -----
421 If ``other`` is empty, `True` is always returned. In all other cases,
422 ``self.contains(other)`` being `True` implies that
423 ``self.overlaps(other)`` is also `True`.
425 Testing whether an instantaneous `astropy.time.Time` value is contained
426 in a timespan is not equivalent to testing a timespan constructed via
427 `Timespan.fromInstant`, because Timespan cannot exactly represent
428 zero-duration intervals. In particular, ``[a, b)`` contains the time
429 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
430 by `Timespan.fromInstant(b)``.
431 """
432 if isinstance(other, astropy.time.Time):
433 nsec = TimeConverter().astropy_to_nsec(other)
434 return self._nsec[0] <= nsec and self._nsec[1] > nsec
435 else:
436 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
438 def intersection(self, *args: Timespan) -> Timespan:
439 """Return a new `Timespan` that is contained by all of the given ones.
441 Parameters
442 ----------
443 *args
444 All positional arguments are `Timespan` instances.
446 Returns
447 -------
448 intersection : `Timespan`
449 The intersection timespan.
450 """
451 if not args:
452 return self
453 lowers = [self._nsec[0]]
454 lowers.extend(ts._nsec[0] for ts in args)
455 uppers = [self._nsec[1]]
456 uppers.extend(ts._nsec[1] for ts in args)
457 nsec = (max(*lowers), min(*uppers))
458 return Timespan(begin=None, end=None, _nsec=nsec)
460 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
461 """Return the one or two timespans that cover the interval(s).
463 The interval is defined as one that is in ``self`` but not ``other``.
465 This is implemented as a generator because the result may be zero, one,
466 or two `Timespan` objects, depending on the relationship between the
467 operands.
469 Parameters
470 ----------
471 other : `Timespan`
472 Timespan to subtract.
474 Yields
475 ------
476 result : `Timespan`
477 A `Timespan` that is contained by ``self`` but does not overlap
478 ``other``. Guaranteed not to be empty.
479 """
480 intersection = self.intersection(other)
481 if intersection.isEmpty():
482 yield self
483 elif intersection == self:
484 yield from ()
485 else:
486 if intersection._nsec[0] > self._nsec[0]:
487 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
488 if intersection._nsec[1] < self._nsec[1]:
489 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
491 def to_simple(self, minimal: bool = False) -> list[int]:
492 """Return simple python type form suitable for serialization.
494 Parameters
495 ----------
496 minimal : `bool`, optional
497 Use minimal serialization. Has no effect on for this class.
499 Returns
500 -------
501 simple : `list` of `int`
502 The internal span as integer nanoseconds.
503 """
504 # Return the internal nanosecond form rather than astropy ISO string
505 return list(self._nsec)
507 @classmethod
508 def from_simple(
509 cls,
510 simple: list[int],
511 universe: DimensionUniverse | None = None,
512 registry: Registry | None = None,
513 ) -> Timespan:
514 """Construct a new object from simplified form.
516 Designed to use the data returned from the `to_simple` method.
518 Parameters
519 ----------
520 simple : `list` of `int`
521 The values returned by `to_simple()`.
522 universe : `DimensionUniverse`, optional
523 Unused.
524 registry : `lsst.daf.butler.Registry`, optional
525 Unused.
527 Returns
528 -------
529 result : `Timespan`
530 Newly-constructed object.
531 """
532 nsec1, nsec2 = simple # for mypy
533 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
535 to_json = to_json_generic
536 from_json: ClassVar = classmethod(from_json_generic)
538 @classmethod
539 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
540 """Convert Timespan into YAML format.
542 This produces a scalar node with a tag "!_SpecialTimespanBound" and
543 value being a name of _SpecialTimespanBound enum.
545 Parameters
546 ----------
547 dumper : `yaml.Dumper`
548 YAML dumper instance.
549 timespan : `Timespan`
550 Data to be converted.
551 """
552 if timespan.isEmpty():
553 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
554 else:
555 return dumper.represent_mapping(
556 cls.yaml_tag,
557 dict(begin=timespan.begin, end=timespan.end),
558 )
560 @classmethod
561 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None:
562 """Convert YAML node into _SpecialTimespanBound.
564 Parameters
565 ----------
566 loader : `yaml.SafeLoader`
567 Instance of YAML loader class.
568 node : `yaml.ScalarNode`
569 YAML node.
571 Returns
572 -------
573 value : `Timespan`
574 Timespan instance, can be ``None``.
575 """
576 if node.value is None:
577 return None
578 elif node.value == "EMPTY":
579 return Timespan.makeEmpty()
580 else:
581 d = loader.construct_mapping(node)
582 return Timespan(d["begin"], d["end"])
585# Register Timespan -> YAML conversion method with Dumper class
586yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
588# Register YAML -> Timespan conversion method with Loader, for our use case we
589# only need SafeLoader.
590yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)
593_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
596class TimespanDatabaseRepresentation(ABC):
597 """An interface for representing a timespan in a database.
599 Notes
600 -----
601 Much of this class's interface is comprised of classmethods. Instances
602 can be constructed via the `from_columns` or `fromLiteral` methods as a
603 way to include timespan overlap operations in query JOIN or WHERE clauses.
605 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
606 same interval definitions and edge-case behavior as the `Timespan` class.
607 They are also guaranteed to round-trip `Timespan` instances exactly.
608 """
610 NAME: ClassVar[str] = "timespan"
612 Compound: ClassVar[type[TimespanDatabaseRepresentation]]
613 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
614 uses two separate fields for the begin (inclusive) and end (exclusive)
615 endpoints.
617 This implementation should be compatible with any SQL database, and should
618 generally be used when a database-specific implementation is not available.
619 """
621 __slots__ = ()
623 @classmethod
624 @abstractmethod
625 def makeFieldSpecs(
626 cls, nullable: bool, name: str | None = None, **kwargs: Any
627 ) -> tuple[ddl.FieldSpec, ...]:
628 """Make objects that reflect the fields that must be added to table.
630 Makes one or more `ddl.FieldSpec` objects that reflect the fields
631 that must be added to a table for this representation.
633 Parameters
634 ----------
635 nullable : `bool`
636 If `True`, the timespan is permitted to be logically ``NULL``
637 (mapped to `None` in Python), though the corresponding value(s) in
638 the database are implementation-defined. Nullable timespan fields
639 default to NULL, while others default to (-∞, ∞).
640 name : `str`, optional
641 Name for the logical column; a part of the name for multi-column
642 representations. Defaults to ``cls.NAME``.
643 **kwargs
644 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
645 for all fields; implementations only provide the ``name``,
646 ``dtype``, and ``default`` arguments themselves.
648 Returns
649 -------
650 specs : `tuple` [ `ddl.FieldSpec` ]
651 Field specification objects; length of the tuple is
652 subclass-dependent, but is guaranteed to match the length of the
653 return values of `getFieldNames` and `update`.
654 """
655 raise NotImplementedError()
657 @classmethod
658 @abstractmethod
659 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
660 """Return the actual field names used by this representation.
662 Parameters
663 ----------
664 name : `str`, optional
665 Name for the logical column; a part of the name for multi-column
666 representations. Defaults to ``cls.NAME``.
668 Returns
669 -------
670 names : `tuple` [ `str` ]
671 Field name(s). Guaranteed to be the same as the names of the field
672 specifications returned by `makeFieldSpecs`.
673 """
674 raise NotImplementedError()
676 @classmethod
677 @abstractmethod
678 def fromLiteral(cls: type[_S], timespan: Timespan | None) -> _S:
679 """Construct a database timespan from a literal `Timespan` instance.
681 Parameters
682 ----------
683 timespan : `Timespan` or `None`
684 Literal timespan to convert, or `None` to make logically ``NULL``
685 timespan.
687 Returns
688 -------
689 tsRepr : `TimespanDatabaseRepresentation`
690 A timespan expression object backed by `sqlalchemy.sql.literal`
691 column expressions.
692 """
693 raise NotImplementedError()
695 @classmethod
696 @abstractmethod
697 def from_columns(cls: type[_S], columns: sqlalchemy.sql.ColumnCollection, name: str | None = None) -> _S:
698 """Construct a database timespan from the columns of a table or
699 subquery.
701 Parameters
702 ----------
703 columns : `sqlalchemy.sql.ColumnCollections`
704 SQLAlchemy container for raw columns.
705 name : `str`, optional
706 Name for the logical column; a part of the name for multi-column
707 representations. Defaults to ``cls.NAME``.
709 Returns
710 -------
711 tsRepr : `TimespanDatabaseRepresentation`
712 A timespan expression object backed by `sqlalchemy.sql.literal`
713 column expressions.
714 """
715 raise NotImplementedError()
717 @classmethod
718 @abstractmethod
719 def update(
720 cls, timespan: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
721 ) -> dict[str, Any]:
722 """Add a timespan value to a dictionary that represents a database row.
724 Parameters
725 ----------
726 timespan
727 A timespan literal, or `None` for ``NULL``.
728 name : `str`, optional
729 Name for the logical column; a part of the name for multi-column
730 representations. Defaults to ``cls.NAME``.
731 result : `dict` [ `str`, `Any` ], optional
732 A dictionary representing a database row that fields should be
733 added to, or `None` to create and return a new one.
735 Returns
736 -------
737 result : `dict` [ `str`, `Any` ]
738 A dictionary containing this representation of a timespan. Exactly
739 the `dict` passed as ``result`` if that is not `None`.
740 """
741 raise NotImplementedError()
743 @classmethod
744 @abstractmethod
745 def extract(cls, mapping: Mapping[Any, Any], name: str | None = None) -> Timespan | None:
746 """Extract a timespan from a dictionary that represents a database row.
748 Parameters
749 ----------
750 mapping : `~collections.abc.Mapping` [ `Any`, `Any` ]
751 A dictionary representing a database row containing a `Timespan`
752 in this representation. Should have key(s) equal to the return
753 value of `getFieldNames`.
754 name : `str`, optional
755 Name for the logical column; a part of the name for multi-column
756 representations. Defaults to ``cls.NAME``.
758 Returns
759 -------
760 timespan : `Timespan` or `None`
761 Python representation of the timespan.
762 """
763 raise NotImplementedError()
765 @classmethod
766 def hasExclusionConstraint(cls) -> bool:
767 """Return `True` if this representation supports exclusion constraints.
769 Returns
770 -------
771 supported : `bool`
772 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
773 includes the fields of this representation is allowed.
774 """
775 return False
777 @property
778 @abstractmethod
779 def name(self) -> str:
780 """Return base logical name for the timespan column or expression
781 (`str`).
783 If the representation uses only one actual column, this should be the
784 full name of the column. In other cases it is an unspecified
785 common subset of the column names.
786 """
787 raise NotImplementedError()
789 @abstractmethod
790 def isNull(self) -> sqlalchemy.sql.ColumnElement:
791 """Return expression that tests whether the timespan is ``NULL``.
793 Returns a SQLAlchemy expression that tests whether this region is
794 logically ``NULL``.
796 Returns
797 -------
798 isnull : `sqlalchemy.sql.ColumnElement`
799 A boolean SQLAlchemy expression object.
800 """
801 raise NotImplementedError()
803 @abstractmethod
804 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
805 """Return the actual column(s) that comprise this logical column.
807 Parameters
808 ----------
809 name : `str`, optional
810 If provided, a name for the logical column that should be used to
811 label the columns. If not provided, the columns' native names will
812 be used.
814 Returns
815 -------
816 columns : `tuple` [ `sqlalchemy.sql.ColumnElement` ]
817 The true column or columns that back this object.
818 """
819 raise NotImplementedError()
821 @abstractmethod
822 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
823 """Return a boolean SQLAlchemy expression for testing empty timespans.
825 Returns
826 -------
827 empty : `sqlalchemy.sql.ColumnElement`
828 A boolean SQLAlchemy expression object.
829 """
830 raise NotImplementedError()
832 @abstractmethod
833 def __lt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
834 """Return SQLAlchemy expression for testing less than.
836 Returns a SQLAlchemy expression representing a test for whether an
837 in-database timespan is strictly less than another timespan or a time
838 point.
840 Parameters
841 ----------
842 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
843 The timespan or time to relate to ``self``; either an instance of
844 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
845 a SQL column expression representing an `astropy.time.Time`.
847 Returns
848 -------
849 less : `sqlalchemy.sql.ColumnElement`
850 A boolean SQLAlchemy expression object.
852 Notes
853 -----
854 See `Timespan.__lt__` for edge-case behavior.
855 """
856 raise NotImplementedError()
858 @abstractmethod
859 def __gt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
860 """Return a SQLAlchemy expression for testing greater than.
862 Returns a SQLAlchemy expression representing a test for whether an
863 in-database timespan is strictly greater than another timespan or a
864 time point.
866 Parameters
867 ----------
868 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
869 The timespan or time to relate to ``self``; either an instance of
870 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
871 a SQL column expression representing an `astropy.time.Time`.
873 Returns
874 -------
875 greater : `sqlalchemy.sql.ColumnElement`
876 A boolean SQLAlchemy expression object.
878 Notes
879 -----
880 See `Timespan.__gt__` for edge-case behavior.
881 """
882 raise NotImplementedError()
884 @abstractmethod
885 def overlaps(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
886 """Return a SQLAlchemy expression representing timespan overlaps.
888 Parameters
889 ----------
890 other : ``type(self)``
891 The timespan or time to overlap ``self`` with. If a single time,
892 this is a synonym for `contains`.
894 Returns
895 -------
896 overlap : `sqlalchemy.sql.ColumnElement`
897 A boolean SQLAlchemy expression object.
899 Notes
900 -----
901 See `Timespan.overlaps` for edge-case behavior.
902 """
903 raise NotImplementedError()
905 @abstractmethod
906 def contains(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
907 """Return a SQLAlchemy expression representing containment.
909 Returns a test for whether an in-database timespan contains another
910 timespan or a time point.
912 Parameters
913 ----------
914 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
915 The timespan or time to relate to ``self``; either an instance of
916 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
917 a SQL column expression representing an `astropy.time.Time`.
919 Returns
920 -------
921 contains : `sqlalchemy.sql.ColumnElement`
922 A boolean SQLAlchemy expression object.
924 Notes
925 -----
926 See `Timespan.contains` for edge-case behavior.
927 """
928 raise NotImplementedError()
930 @abstractmethod
931 def lower(self: _S) -> sqlalchemy.sql.ColumnElement:
932 """Return a SQLAlchemy expression representing a lower bound of a
933 timespan.
935 Returns
936 -------
937 lower : `sqlalchemy.sql.ColumnElement`
938 A SQLAlchemy expression for a lower bound.
940 Notes
941 -----
942 If database holds ``NULL`` for a timespan then the returned expression
943 should evaluate to 0. Main purpose of this and `upper` method is to use
944 them in generating SQL, in particular ORDER BY clause, to guarantee a
945 predictable ordering. It may potentially be used for transforming
946 boolean user expressions into SQL, but it will likely require extra
947 attention to ordering issues.
948 """
949 raise NotImplementedError()
951 @abstractmethod
952 def upper(self: _S) -> sqlalchemy.sql.ColumnElement:
953 """Return a SQLAlchemy expression representing an upper bound of a
954 timespan.
956 Returns
957 -------
958 upper : `sqlalchemy.sql.ColumnElement`
959 A SQLAlchemy expression for an upper bound.
961 Notes
962 -----
963 If database holds ``NULL`` for a timespan then the returned expression
964 should evaluate to 0. Also see notes for `lower` method.
965 """
966 raise NotImplementedError()
969class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
970 """Representation of a time span as two separate fields.
972 An implementation of `TimespanDatabaseRepresentation` that simply stores
973 the endpoints in two separate fields.
975 This type should generally be accessed via
976 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
977 via the `from_columns` and `fromLiteral` methods.
979 Parameters
980 ----------
981 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
982 Tuple of SQLAlchemy objects representing the lower (inclusive) and
983 upper (exclusive) bounds, as 64-bit integer columns containing
984 nanoseconds.
985 name : `str`, optional
986 Name for the logical column; a part of the name for multi-column
987 representations. Defaults to ``cls.NAME``.
989 Notes
990 -----
991 ``NULL`` timespans are represented by having both fields set to ``NULL``;
992 setting only one to ``NULL`` is considered a corrupted state that should
993 only be possible if this interface is circumvented. `Timespan` instances
994 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
995 are set to fields mapped to the minimum and maximum value constants used
996 by our integer-time mapping.
997 """
999 def __init__(self, nsec: tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
1000 self._nsec = nsec
1001 self._name = name
1003 __slots__ = ("_nsec", "_name")
1005 @classmethod
1006 def makeFieldSpecs(
1007 cls, nullable: bool, name: str | None = None, **kwargs: Any
1008 ) -> tuple[ddl.FieldSpec, ...]:
1009 # Docstring inherited.
1010 if name is None:
1011 name = cls.NAME
1012 return (
1013 ddl.FieldSpec(
1014 f"{name}_begin",
1015 dtype=sqlalchemy.BigInteger,
1016 nullable=nullable,
1017 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
1018 **kwargs,
1019 ),
1020 ddl.FieldSpec(
1021 f"{name}_end",
1022 dtype=sqlalchemy.BigInteger,
1023 nullable=nullable,
1024 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
1025 **kwargs,
1026 ),
1027 )
1029 @classmethod
1030 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
1031 # Docstring inherited.
1032 if name is None:
1033 name = cls.NAME
1034 return (f"{name}_begin", f"{name}_end")
1036 @classmethod
1037 def update(
1038 cls, extent: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
1039 ) -> dict[str, Any]:
1040 # Docstring inherited.
1041 if name is None:
1042 name = cls.NAME
1043 if result is None:
1044 result = {}
1045 if extent is None:
1046 begin_nsec = None
1047 end_nsec = None
1048 else:
1049 begin_nsec = extent._nsec[0]
1050 end_nsec = extent._nsec[1]
1051 result[f"{name}_begin"] = begin_nsec
1052 result[f"{name}_end"] = end_nsec
1053 return result
1055 @classmethod
1056 def extract(cls, mapping: Mapping[str, Any], name: str | None = None) -> Timespan | None:
1057 # Docstring inherited.
1058 if name is None:
1059 name = cls.NAME
1060 begin_nsec = mapping[f"{name}_begin"]
1061 end_nsec = mapping[f"{name}_end"]
1062 if begin_nsec is None:
1063 if end_nsec is not None:
1064 raise RuntimeError(
1065 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
1066 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
1067 )
1068 return None
1069 elif end_nsec is None:
1070 raise RuntimeError(
1071 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
1072 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
1073 )
1074 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
1076 @classmethod
1077 def from_columns(
1078 cls, columns: sqlalchemy.sql.ColumnCollection, name: str | None = None
1079 ) -> _CompoundTimespanDatabaseRepresentation:
1080 # Docstring inherited.
1081 if name is None:
1082 name = cls.NAME
1083 return cls(nsec=(columns[f"{name}_begin"], columns[f"{name}_end"]), name=name)
1085 @classmethod
1086 def fromLiteral(cls, timespan: Timespan | None) -> _CompoundTimespanDatabaseRepresentation:
1087 # Docstring inherited.
1088 if timespan is None:
1089 return cls(nsec=(sqlalchemy.sql.null(), sqlalchemy.sql.null()), name=cls.NAME)
1090 return cls(
1091 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
1092 name=cls.NAME,
1093 )
1095 @property
1096 def name(self) -> str:
1097 # Docstring inherited.
1098 return self._name
1100 def isNull(self) -> sqlalchemy.sql.ColumnElement:
1101 # Docstring inherited.
1102 return self._nsec[0].is_(None)
1104 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
1105 # Docstring inherited.
1106 return self._nsec[0] >= self._nsec[1]
1108 def __lt__(
1109 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1110 ) -> sqlalchemy.sql.ColumnElement:
1111 # Docstring inherited.
1112 # See comments in Timespan.__lt__ for why we use these exact
1113 # expressions.
1114 if isinstance(other, sqlalchemy.sql.ColumnElement):
1115 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
1116 else:
1117 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
1119 def __gt__(
1120 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1121 ) -> sqlalchemy.sql.ColumnElement:
1122 # Docstring inherited.
1123 # See comments in Timespan.__gt__ for why we use these exact
1124 # expressions.
1125 if isinstance(other, sqlalchemy.sql.ColumnElement):
1126 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
1127 else:
1128 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
1130 def overlaps(
1131 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1132 ) -> sqlalchemy.sql.ColumnElement:
1133 # Docstring inherited.
1134 if isinstance(other, sqlalchemy.sql.ColumnElement):
1135 return self.contains(other)
1136 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
1138 def contains(
1139 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1140 ) -> sqlalchemy.sql.ColumnElement:
1141 # Docstring inherited.
1142 if isinstance(other, sqlalchemy.sql.ColumnElement):
1143 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
1144 else:
1145 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
1147 def lower(self) -> sqlalchemy.sql.ColumnElement:
1148 # Docstring inherited.
1149 return sqlalchemy.sql.functions.coalesce(self._nsec[0], sqlalchemy.sql.literal(0))
1151 def upper(self) -> sqlalchemy.sql.ColumnElement:
1152 # Docstring inherited.
1153 return sqlalchemy.sql.functions.coalesce(self._nsec[1], sqlalchemy.sql.literal(0))
1155 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
1156 # Docstring inherited.
1157 if name is None:
1158 return self._nsec
1159 else:
1160 return (
1161 self._nsec[0].label(f"{name}_begin"),
1162 self._nsec[1].label(f"{name}_end"),
1163 )
1166TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation