Coverage for python/lsst/daf/butler/_timespan.py: 44%
317 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-06 10:53 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-06 10:53 +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 fmt = "%Y-%m-%dT%H:%M:%S"
283 # Trap dubious year warnings in case we have timespans from
284 # simulated data in the future
285 with warnings.catch_warnings():
286 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
287 if erfa is not None:
288 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
289 if self.begin is None:
290 head = "(-∞, "
291 else:
292 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor"
293 head = f"[{self.begin.tai.strftime(fmt)}, "
294 if self.end is None:
295 tail = "∞)"
296 else:
297 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor"
298 tail = f"{self.end.tai.strftime(fmt)})"
299 return head + tail
301 def __repr__(self) -> str:
302 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
303 # simulate our own here to make Timespan's __repr__ eval-friendly.
304 # Interestingly, enum.Enum has an eval-friendly __str__, but not an
305 # eval-friendly __repr__.
306 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
307 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin)
308 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end)
309 return f"Timespan(begin={begin}, end={end})"
311 def __eq__(self, other: Any) -> bool:
312 if not isinstance(other, Timespan):
313 return False
314 # Correctness of this simple implementation depends on __init__
315 # standardizing all empty timespans to a single value.
316 return self._nsec == other._nsec
318 def __hash__(self) -> int:
319 # Correctness of this simple implementation depends on __init__
320 # standardizing all empty timespans to a single value.
321 return hash(self._nsec)
323 def __reduce__(self) -> tuple:
324 return (Timespan, (None, None, False, self._nsec))
326 def __lt__(self, other: astropy.time.Time | Timespan) -> bool:
327 """Test if a Timespan's bounds are strictly less than the given time.
329 Parameters
330 ----------
331 other : `Timespan` or `astropy.time.Time`.
332 Timespan or instant in time to relate to ``self``.
334 Returns
335 -------
336 less : `bool`
337 The result of the less-than test. `False` if either operand is
338 empty.
339 """
340 # First term in each expression below is the "normal" one; the second
341 # ensures correct behavior for empty timespans. It's important that
342 # the second uses a strict inequality to make sure inf == inf isn't in
343 # play, and it's okay for the second to use a strict inequality only
344 # because we know non-empty Timespans have nonzero duration, and hence
345 # the second term is never false for non-empty timespans unless the
346 # first term is also false.
347 if isinstance(other, astropy.time.Time):
348 nsec = TimeConverter().astropy_to_nsec(other)
349 return self._nsec[1] <= nsec and self._nsec[0] < nsec
350 else:
351 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1]
353 def __gt__(self, other: astropy.time.Time | Timespan) -> bool:
354 """Test if a Timespan's bounds are strictly greater than given time.
356 Parameters
357 ----------
358 other : `Timespan` or `astropy.time.Time`.
359 Timespan or instant in time to relate to ``self``.
361 Returns
362 -------
363 greater : `bool`
364 The result of the greater-than test. `False` if either operand is
365 empty.
366 """
367 # First term in each expression below is the "normal" one; the second
368 # ensures correct behavior for empty timespans. It's important that
369 # the second uses a strict inequality to make sure inf == inf isn't in
370 # play, and it's okay for the second to use a strict inequality only
371 # because we know non-empty Timespans have nonzero duration, and hence
372 # the second term is never false for non-empty timespans unless the
373 # first term is also false.
374 if isinstance(other, astropy.time.Time):
375 nsec = TimeConverter().astropy_to_nsec(other)
376 return self._nsec[0] > nsec and self._nsec[1] > nsec
377 else:
378 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0]
380 def overlaps(self, other: Timespan | astropy.time.Time) -> bool:
381 """Test if the intersection of this Timespan with another is empty.
383 Parameters
384 ----------
385 other : `Timespan` or `astropy.time.Time`
386 Timespan or time to relate to ``self``. If a single time, this is
387 a synonym for `contains`.
389 Returns
390 -------
391 overlaps : `bool`
392 The result of the overlap test.
394 Notes
395 -----
396 If either ``self`` or ``other`` is empty, the result is always `False`.
397 In all other cases, ``self.contains(other)`` being `True` implies that
398 ``self.overlaps(other)`` is also `True`.
399 """
400 if isinstance(other, astropy.time.Time):
401 return self.contains(other)
402 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0]
404 def contains(self, other: astropy.time.Time | Timespan) -> bool:
405 """Test if the supplied timespan is within this one.
407 Tests whether the intersection of this timespan with another timespan
408 or point is equal to the other one.
410 Parameters
411 ----------
412 other : `Timespan` or `astropy.time.Time`.
413 Timespan or instant in time to relate to ``self``.
415 Returns
416 -------
417 overlaps : `bool`
418 The result of the contains test.
420 Notes
421 -----
422 If ``other`` is empty, `True` is always returned. In all other cases,
423 ``self.contains(other)`` being `True` implies that
424 ``self.overlaps(other)`` is also `True`.
426 Testing whether an instantaneous `astropy.time.Time` value is contained
427 in a timespan is not equivalent to testing a timespan constructed via
428 `Timespan.fromInstant`, because Timespan cannot exactly represent
429 zero-duration intervals. In particular, ``[a, b)`` contains the time
430 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned
431 by `Timespan.fromInstant(b)``.
432 """
433 if isinstance(other, astropy.time.Time):
434 nsec = TimeConverter().astropy_to_nsec(other)
435 return self._nsec[0] <= nsec and self._nsec[1] > nsec
436 else:
437 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1]
439 def intersection(self, *args: Timespan) -> Timespan:
440 """Return a new `Timespan` that is contained by all of the given ones.
442 Parameters
443 ----------
444 *args
445 All positional arguments are `Timespan` instances.
447 Returns
448 -------
449 intersection : `Timespan`
450 The intersection timespan.
451 """
452 if not args:
453 return self
454 lowers = [self._nsec[0]]
455 lowers.extend(ts._nsec[0] for ts in args)
456 uppers = [self._nsec[1]]
457 uppers.extend(ts._nsec[1] for ts in args)
458 nsec = (max(*lowers), min(*uppers))
459 return Timespan(begin=None, end=None, _nsec=nsec)
461 def difference(self, other: Timespan) -> Generator[Timespan, None, None]:
462 """Return the one or two timespans that cover the interval(s).
464 The interval is defined as one that is in ``self`` but not ``other``.
466 This is implemented as a generator because the result may be zero, one,
467 or two `Timespan` objects, depending on the relationship between the
468 operands.
470 Parameters
471 ----------
472 other : `Timespan`
473 Timespan to subtract.
475 Yields
476 ------
477 result : `Timespan`
478 A `Timespan` that is contained by ``self`` but does not overlap
479 ``other``. Guaranteed not to be empty.
480 """
481 intersection = self.intersection(other)
482 if intersection.isEmpty():
483 yield self
484 elif intersection == self:
485 yield from ()
486 else:
487 if intersection._nsec[0] > self._nsec[0]:
488 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0]))
489 if intersection._nsec[1] < self._nsec[1]:
490 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1]))
492 def to_simple(self, minimal: bool = False) -> list[int]:
493 """Return simple python type form suitable for serialization.
495 Parameters
496 ----------
497 minimal : `bool`, optional
498 Use minimal serialization. Has no effect on for this class.
500 Returns
501 -------
502 simple : `list` of `int`
503 The internal span as integer nanoseconds.
504 """
505 # Return the internal nanosecond form rather than astropy ISO string
506 return list(self._nsec)
508 @classmethod
509 def from_simple(
510 cls,
511 simple: list[int] | None,
512 universe: DimensionUniverse | None = None,
513 registry: Registry | None = None,
514 ) -> Timespan | None:
515 """Construct a new object from simplified form.
517 Designed to use the data returned from the `to_simple` method.
519 Parameters
520 ----------
521 simple : `list` of `int`, or `None`
522 The values returned by `to_simple()`.
523 universe : `DimensionUniverse`, optional
524 Unused.
525 registry : `lsst.daf.butler.Registry`, optional
526 Unused.
528 Returns
529 -------
530 result : `Timespan` or `None`
531 Newly-constructed object.
532 """
533 if simple is None:
534 return None
535 nsec1, nsec2 = simple # for mypy
536 return cls(begin=None, end=None, _nsec=(nsec1, nsec2))
538 to_json = to_json_generic
539 from_json: ClassVar = classmethod(from_json_generic)
541 @classmethod
542 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any:
543 """Convert Timespan into YAML format.
545 This produces a scalar node with a tag "!_SpecialTimespanBound" and
546 value being a name of _SpecialTimespanBound enum.
548 Parameters
549 ----------
550 dumper : `yaml.Dumper`
551 YAML dumper instance.
552 timespan : `Timespan`
553 Data to be converted.
554 """
555 if timespan.isEmpty():
556 return dumper.represent_scalar(cls.yaml_tag, "EMPTY")
557 else:
558 return dumper.represent_mapping(
559 cls.yaml_tag,
560 dict(begin=timespan.begin, end=timespan.end),
561 )
563 @classmethod
564 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None:
565 """Convert YAML node into _SpecialTimespanBound.
567 Parameters
568 ----------
569 loader : `yaml.SafeLoader`
570 Instance of YAML loader class.
571 node : `yaml.ScalarNode`
572 YAML node.
574 Returns
575 -------
576 value : `Timespan`
577 Timespan instance, can be ``None``.
578 """
579 if node.value is None:
580 return None
581 elif node.value == "EMPTY":
582 return Timespan.makeEmpty()
583 else:
584 d = loader.construct_mapping(node)
585 return Timespan(d["begin"], d["end"])
588# Register Timespan -> YAML conversion method with Dumper class
589yaml.Dumper.add_representer(Timespan, Timespan.to_yaml)
591# Register YAML -> Timespan conversion method with Loader, for our use case we
592# only need SafeLoader.
593yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)
596_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
599class TimespanDatabaseRepresentation(ABC):
600 """An interface for representing a timespan in a database.
602 Notes
603 -----
604 Much of this class's interface is comprised of classmethods. Instances
605 can be constructed via the `from_columns` or `fromLiteral` methods as a
606 way to include timespan overlap operations in query JOIN or WHERE clauses.
608 `TimespanDatabaseRepresentation` implementations are guaranteed to use the
609 same interval definitions and edge-case behavior as the `Timespan` class.
610 They are also guaranteed to round-trip `Timespan` instances exactly.
611 """
613 NAME: ClassVar[str] = "timespan"
615 Compound: ClassVar[type[TimespanDatabaseRepresentation]]
616 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
617 uses two separate fields for the begin (inclusive) and end (exclusive)
618 endpoints.
620 This implementation should be compatible with any SQL database, and should
621 generally be used when a database-specific implementation is not available.
622 """
624 __slots__ = ()
626 @classmethod
627 @abstractmethod
628 def makeFieldSpecs(
629 cls, nullable: bool, name: str | None = None, **kwargs: Any
630 ) -> tuple[ddl.FieldSpec, ...]:
631 """Make objects that reflect the fields that must be added to table.
633 Makes one or more `ddl.FieldSpec` objects that reflect the fields
634 that must be added to a table for this representation.
636 Parameters
637 ----------
638 nullable : `bool`
639 If `True`, the timespan is permitted to be logically ``NULL``
640 (mapped to `None` in Python), though the corresponding value(s) in
641 the database are implementation-defined. Nullable timespan fields
642 default to NULL, while others default to (-∞, ∞).
643 name : `str`, optional
644 Name for the logical column; a part of the name for multi-column
645 representations. Defaults to ``cls.NAME``.
646 **kwargs
647 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
648 for all fields; implementations only provide the ``name``,
649 ``dtype``, and ``default`` arguments themselves.
651 Returns
652 -------
653 specs : `tuple` [ `ddl.FieldSpec` ]
654 Field specification objects; length of the tuple is
655 subclass-dependent, but is guaranteed to match the length of the
656 return values of `getFieldNames` and `update`.
657 """
658 raise NotImplementedError()
660 @classmethod
661 @abstractmethod
662 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
663 """Return the actual field names used by this representation.
665 Parameters
666 ----------
667 name : `str`, optional
668 Name for the logical column; a part of the name for multi-column
669 representations. Defaults to ``cls.NAME``.
671 Returns
672 -------
673 names : `tuple` [ `str` ]
674 Field name(s). Guaranteed to be the same as the names of the field
675 specifications returned by `makeFieldSpecs`.
676 """
677 raise NotImplementedError()
679 @classmethod
680 @abstractmethod
681 def fromLiteral(cls: type[_S], timespan: Timespan | None) -> _S:
682 """Construct a database timespan from a literal `Timespan` instance.
684 Parameters
685 ----------
686 timespan : `Timespan` or `None`
687 Literal timespan to convert, or `None` to make logically ``NULL``
688 timespan.
690 Returns
691 -------
692 tsRepr : `TimespanDatabaseRepresentation`
693 A timespan expression object backed by `sqlalchemy.sql.literal`
694 column expressions.
695 """
696 raise NotImplementedError()
698 @classmethod
699 @abstractmethod
700 def from_columns(cls: type[_S], columns: sqlalchemy.sql.ColumnCollection, name: str | None = None) -> _S:
701 """Construct a database timespan from the columns of a table or
702 subquery.
704 Parameters
705 ----------
706 columns : `sqlalchemy.sql.ColumnCollections`
707 SQLAlchemy container for raw columns.
708 name : `str`, optional
709 Name for the logical column; a part of the name for multi-column
710 representations. Defaults to ``cls.NAME``.
712 Returns
713 -------
714 tsRepr : `TimespanDatabaseRepresentation`
715 A timespan expression object backed by `sqlalchemy.sql.literal`
716 column expressions.
717 """
718 raise NotImplementedError()
720 @classmethod
721 @abstractmethod
722 def update(
723 cls, timespan: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
724 ) -> dict[str, Any]:
725 """Add a timespan value to a dictionary that represents a database row.
727 Parameters
728 ----------
729 timespan
730 A timespan literal, or `None` for ``NULL``.
731 name : `str`, optional
732 Name for the logical column; a part of the name for multi-column
733 representations. Defaults to ``cls.NAME``.
734 result : `dict` [ `str`, `Any` ], optional
735 A dictionary representing a database row that fields should be
736 added to, or `None` to create and return a new one.
738 Returns
739 -------
740 result : `dict` [ `str`, `Any` ]
741 A dictionary containing this representation of a timespan. Exactly
742 the `dict` passed as ``result`` if that is not `None`.
743 """
744 raise NotImplementedError()
746 @classmethod
747 @abstractmethod
748 def extract(cls, mapping: Mapping[Any, Any], name: str | None = None) -> Timespan | None:
749 """Extract a timespan from a dictionary that represents a database row.
751 Parameters
752 ----------
753 mapping : `~collections.abc.Mapping` [ `Any`, `Any` ]
754 A dictionary representing a database row containing a `Timespan`
755 in this representation. Should have key(s) equal to the return
756 value of `getFieldNames`.
757 name : `str`, optional
758 Name for the logical column; a part of the name for multi-column
759 representations. Defaults to ``cls.NAME``.
761 Returns
762 -------
763 timespan : `Timespan` or `None`
764 Python representation of the timespan.
765 """
766 raise NotImplementedError()
768 @classmethod
769 def hasExclusionConstraint(cls) -> bool:
770 """Return `True` if this representation supports exclusion constraints.
772 Returns
773 -------
774 supported : `bool`
775 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
776 includes the fields of this representation is allowed.
777 """
778 return False
780 @property
781 @abstractmethod
782 def name(self) -> str:
783 """Return base logical name for the timespan column or expression
784 (`str`).
786 If the representation uses only one actual column, this should be the
787 full name of the column. In other cases it is an unspecified
788 common subset of the column names.
789 """
790 raise NotImplementedError()
792 @abstractmethod
793 def isNull(self) -> sqlalchemy.sql.ColumnElement:
794 """Return expression that tests whether the timespan is ``NULL``.
796 Returns a SQLAlchemy expression that tests whether this region is
797 logically ``NULL``.
799 Returns
800 -------
801 isnull : `sqlalchemy.sql.ColumnElement`
802 A boolean SQLAlchemy expression object.
803 """
804 raise NotImplementedError()
806 @abstractmethod
807 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
808 """Return the actual column(s) that comprise this logical column.
810 Parameters
811 ----------
812 name : `str`, optional
813 If provided, a name for the logical column that should be used to
814 label the columns. If not provided, the columns' native names will
815 be used.
817 Returns
818 -------
819 columns : `tuple` [ `sqlalchemy.sql.ColumnElement` ]
820 The true column or columns that back this object.
821 """
822 raise NotImplementedError()
824 @abstractmethod
825 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
826 """Return a boolean SQLAlchemy expression for testing empty timespans.
828 Returns
829 -------
830 empty : `sqlalchemy.sql.ColumnElement`
831 A boolean SQLAlchemy expression object.
832 """
833 raise NotImplementedError()
835 @abstractmethod
836 def __lt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
837 """Return SQLAlchemy expression for testing less than.
839 Returns a SQLAlchemy expression representing a test for whether an
840 in-database timespan is strictly less than another timespan or a time
841 point.
843 Parameters
844 ----------
845 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
846 The timespan or time to relate to ``self``; either an instance of
847 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
848 a SQL column expression representing an `astropy.time.Time`.
850 Returns
851 -------
852 less : `sqlalchemy.sql.ColumnElement`
853 A boolean SQLAlchemy expression object.
855 Notes
856 -----
857 See `Timespan.__lt__` for edge-case behavior.
858 """
859 raise NotImplementedError()
861 @abstractmethod
862 def __gt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
863 """Return a SQLAlchemy expression for testing greater than.
865 Returns a SQLAlchemy expression representing a test for whether an
866 in-database timespan is strictly greater than another timespan or a
867 time point.
869 Parameters
870 ----------
871 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
872 The timespan or time to relate to ``self``; either an instance of
873 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
874 a SQL column expression representing an `astropy.time.Time`.
876 Returns
877 -------
878 greater : `sqlalchemy.sql.ColumnElement`
879 A boolean SQLAlchemy expression object.
881 Notes
882 -----
883 See `Timespan.__gt__` for edge-case behavior.
884 """
885 raise NotImplementedError()
887 @abstractmethod
888 def overlaps(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
889 """Return a SQLAlchemy expression representing timespan overlaps.
891 Parameters
892 ----------
893 other : ``type(self)``
894 The timespan or time to overlap ``self`` with. If a single time,
895 this is a synonym for `contains`.
897 Returns
898 -------
899 overlap : `sqlalchemy.sql.ColumnElement`
900 A boolean SQLAlchemy expression object.
902 Notes
903 -----
904 See `Timespan.overlaps` for edge-case behavior.
905 """
906 raise NotImplementedError()
908 @abstractmethod
909 def contains(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement:
910 """Return a SQLAlchemy expression representing containment.
912 Returns a test for whether an in-database timespan contains another
913 timespan or a time point.
915 Parameters
916 ----------
917 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement`
918 The timespan or time to relate to ``self``; either an instance of
919 the same `TimespanDatabaseRepresentation` subclass as ``self``, or
920 a SQL column expression representing an `astropy.time.Time`.
922 Returns
923 -------
924 contains : `sqlalchemy.sql.ColumnElement`
925 A boolean SQLAlchemy expression object.
927 Notes
928 -----
929 See `Timespan.contains` for edge-case behavior.
930 """
931 raise NotImplementedError()
933 @abstractmethod
934 def lower(self: _S) -> sqlalchemy.sql.ColumnElement:
935 """Return a SQLAlchemy expression representing a lower bound of a
936 timespan.
938 Returns
939 -------
940 lower : `sqlalchemy.sql.ColumnElement`
941 A SQLAlchemy expression for a lower bound.
943 Notes
944 -----
945 If database holds ``NULL`` for a timespan then the returned expression
946 should evaluate to 0. Main purpose of this and `upper` method is to use
947 them in generating SQL, in particular ORDER BY clause, to guarantee a
948 predictable ordering. It may potentially be used for transforming
949 boolean user expressions into SQL, but it will likely require extra
950 attention to ordering issues.
951 """
952 raise NotImplementedError()
954 @abstractmethod
955 def upper(self: _S) -> sqlalchemy.sql.ColumnElement:
956 """Return a SQLAlchemy expression representing an upper bound of a
957 timespan.
959 Returns
960 -------
961 upper : `sqlalchemy.sql.ColumnElement`
962 A SQLAlchemy expression for an upper bound.
964 Notes
965 -----
966 If database holds ``NULL`` for a timespan then the returned expression
967 should evaluate to 0. Also see notes for `lower` method.
968 """
969 raise NotImplementedError()
972class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
973 """Representation of a time span as two separate fields.
975 An implementation of `TimespanDatabaseRepresentation` that simply stores
976 the endpoints in two separate fields.
978 This type should generally be accessed via
979 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
980 via the `from_columns` and `fromLiteral` methods.
982 Parameters
983 ----------
984 nsec : `tuple` of `sqlalchemy.sql.ColumnElement`
985 Tuple of SQLAlchemy objects representing the lower (inclusive) and
986 upper (exclusive) bounds, as 64-bit integer columns containing
987 nanoseconds.
988 name : `str`, optional
989 Name for the logical column; a part of the name for multi-column
990 representations. Defaults to ``cls.NAME``.
992 Notes
993 -----
994 ``NULL`` timespans are represented by having both fields set to ``NULL``;
995 setting only one to ``NULL`` is considered a corrupted state that should
996 only be possible if this interface is circumvented. `Timespan` instances
997 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
998 are set to fields mapped to the minimum and maximum value constants used
999 by our integer-time mapping.
1000 """
1002 def __init__(self, nsec: tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str):
1003 self._nsec = nsec
1004 self._name = name
1006 __slots__ = ("_nsec", "_name")
1008 @classmethod
1009 def makeFieldSpecs(
1010 cls, nullable: bool, name: str | None = None, **kwargs: Any
1011 ) -> tuple[ddl.FieldSpec, ...]:
1012 # Docstring inherited.
1013 if name is None:
1014 name = cls.NAME
1015 return (
1016 ddl.FieldSpec(
1017 f"{name}_begin",
1018 dtype=sqlalchemy.BigInteger,
1019 nullable=nullable,
1020 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))),
1021 **kwargs,
1022 ),
1023 ddl.FieldSpec(
1024 f"{name}_end",
1025 dtype=sqlalchemy.BigInteger,
1026 nullable=nullable,
1027 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))),
1028 **kwargs,
1029 ),
1030 )
1032 @classmethod
1033 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]:
1034 # Docstring inherited.
1035 if name is None:
1036 name = cls.NAME
1037 return (f"{name}_begin", f"{name}_end")
1039 @classmethod
1040 def update(
1041 cls, extent: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None
1042 ) -> dict[str, Any]:
1043 # Docstring inherited.
1044 if name is None:
1045 name = cls.NAME
1046 if result is None:
1047 result = {}
1048 if extent is None:
1049 begin_nsec = None
1050 end_nsec = None
1051 else:
1052 begin_nsec = extent._nsec[0]
1053 end_nsec = extent._nsec[1]
1054 result[f"{name}_begin"] = begin_nsec
1055 result[f"{name}_end"] = end_nsec
1056 return result
1058 @classmethod
1059 def extract(cls, mapping: Mapping[str, Any], name: str | None = None) -> Timespan | None:
1060 # Docstring inherited.
1061 if name is None:
1062 name = cls.NAME
1063 begin_nsec = mapping[f"{name}_begin"]
1064 end_nsec = mapping[f"{name}_end"]
1065 if begin_nsec is None:
1066 if end_nsec is not None:
1067 raise RuntimeError(
1068 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> "
1069 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}."
1070 )
1071 return None
1072 elif end_nsec is None:
1073 raise RuntimeError(
1074 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> "
1075 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}."
1076 )
1077 return Timespan(None, None, _nsec=(begin_nsec, end_nsec))
1079 @classmethod
1080 def from_columns(
1081 cls, columns: sqlalchemy.sql.ColumnCollection, name: str | None = None
1082 ) -> _CompoundTimespanDatabaseRepresentation:
1083 # Docstring inherited.
1084 if name is None:
1085 name = cls.NAME
1086 return cls(nsec=(columns[f"{name}_begin"], columns[f"{name}_end"]), name=name)
1088 @classmethod
1089 def fromLiteral(cls, timespan: Timespan | None) -> _CompoundTimespanDatabaseRepresentation:
1090 # Docstring inherited.
1091 if timespan is None:
1092 return cls(nsec=(sqlalchemy.sql.null(), sqlalchemy.sql.null()), name=cls.NAME)
1093 return cls(
1094 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])),
1095 name=cls.NAME,
1096 )
1098 @property
1099 def name(self) -> str:
1100 # Docstring inherited.
1101 return self._name
1103 def isNull(self) -> sqlalchemy.sql.ColumnElement:
1104 # Docstring inherited.
1105 return self._nsec[0].is_(None)
1107 def isEmpty(self) -> sqlalchemy.sql.ColumnElement:
1108 # Docstring inherited.
1109 return self._nsec[0] >= self._nsec[1]
1111 def __lt__(
1112 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1113 ) -> sqlalchemy.sql.ColumnElement:
1114 # Docstring inherited.
1115 # See comments in Timespan.__lt__ for why we use these exact
1116 # expressions.
1117 if isinstance(other, sqlalchemy.sql.ColumnElement):
1118 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other)
1119 else:
1120 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1])
1122 def __gt__(
1123 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1124 ) -> sqlalchemy.sql.ColumnElement:
1125 # Docstring inherited.
1126 # See comments in Timespan.__gt__ for why we use these exact
1127 # expressions.
1128 if isinstance(other, sqlalchemy.sql.ColumnElement):
1129 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other)
1130 else:
1131 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0])
1133 def overlaps(
1134 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1135 ) -> sqlalchemy.sql.ColumnElement:
1136 # Docstring inherited.
1137 if isinstance(other, sqlalchemy.sql.ColumnElement):
1138 return self.contains(other)
1139 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0])
1141 def contains(
1142 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement
1143 ) -> sqlalchemy.sql.ColumnElement:
1144 # Docstring inherited.
1145 if isinstance(other, sqlalchemy.sql.ColumnElement):
1146 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other)
1147 else:
1148 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1])
1150 def lower(self) -> sqlalchemy.sql.ColumnElement:
1151 # Docstring inherited.
1152 return sqlalchemy.sql.functions.coalesce(self._nsec[0], sqlalchemy.sql.literal(0))
1154 def upper(self) -> sqlalchemy.sql.ColumnElement:
1155 # Docstring inherited.
1156 return sqlalchemy.sql.functions.coalesce(self._nsec[1], sqlalchemy.sql.literal(0))
1158 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]:
1159 # Docstring inherited.
1160 if name is None:
1161 return self._nsec
1162 else:
1163 return (
1164 self._nsec[0].label(f"{name}_begin"),
1165 self._nsec[1].label(f"{name}_end"),
1166 )
1169TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation