Coverage for python/lsst/daf/butler/core/timespan.py : 23%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = (
24 "Timespan",
25 "TimespanDatabaseRepresentation",
26)
28from abc import abstractmethod
29from typing import Any, ClassVar, Dict, Iterator, Mapping, NamedTuple, Optional, Tuple, Type, TypeVar, Union
31import astropy.time
32import astropy.utils.exceptions
33import sqlalchemy
34import warnings
36# As of astropy 4.2, the erfa interface is shipped independently and
37# ErfaWarning is no longer an AstropyWarning
38try:
39 import erfa
40except ImportError:
41 erfa = None
43from . import ddl
44from .time_utils import astropy_to_nsec, EPOCH, MAX_TIME, times_equal
45from ._topology import TopologicalExtentDatabaseRepresentation
48class Timespan(NamedTuple):
49 """A 2-element named tuple for time intervals.
51 Parameters
52 ----------
53 begin : ``Timespan``
54 Minimum timestamp in the interval (inclusive). `None` is interpreted
55 as -infinity.
56 end : ``Timespan``
57 Maximum timestamp in the interval (exclusive). `None` is interpreted
58 as +infinity.
59 """
61 begin: Optional[astropy.time.Time]
62 """Minimum timestamp in the interval (inclusive).
64 `None` should be interpreted as -infinity.
65 """
67 end: Optional[astropy.time.Time]
68 """Maximum timestamp in the interval (exclusive).
70 `None` should be interpreted as +infinity.
71 """
73 def __str__(self) -> str:
74 # Trap dubious year warnings in case we have timespans from
75 # simulated data in the future
76 with warnings.catch_warnings():
77 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
78 if erfa is not None:
79 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
80 if self.begin is None:
81 head = "(-∞, "
82 else:
83 head = f"[{self.begin.tai.isot}, "
84 if self.end is None:
85 tail = "∞)"
86 else:
87 tail = f"{self.end.tai.isot})"
88 return head + tail
90 def __repr__(self) -> str:
91 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
92 # simulate our own here to make Timespan's __repr__ eval-friendly.
93 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
94 begin = tmpl.format(t=self.begin) if self.begin is not None else None
95 end = tmpl.format(t=self.end) if self.end is not None else None
96 return f"Timespan(begin={begin}, end={end})"
98 def __eq__(self, other: Any) -> bool:
99 # Include some fuzziness in equality because round tripping
100 # can introduce some drift at the picosecond level
101 # Butler is okay wih nanosecond precision
102 if not isinstance(other, type(self)):
103 return False
105 def compare_time(t1: Optional[astropy.time.Time], t2: Optional[astropy.time.Time]) -> bool:
106 if t1 is None and t2 is None:
107 return True
108 if t1 is None or t2 is None:
109 return False
111 return times_equal(t1, t2)
113 result = compare_time(self.begin, other.begin) and compare_time(self.end, other.end)
114 return result
116 def __ne__(self, other: Any) -> bool:
117 # Need to override the explicit parent class implementation
118 return not self.__eq__(other)
120 def overlaps(self, other: Timespan) -> Any:
121 """Test whether this timespan overlaps another.
123 Parameters
124 ----------
125 other : `Timespan`
126 Another timespan whose begin and end values can be compared with
127 those of ``self`` with the ``>=`` operator, yielding values
128 that can be passed to ``ops.or_`` and/or ``ops.and_``.
130 Returns
131 -------
132 overlaps : `Any`
133 The result of the overlap. When ``ops`` is `operator`, this will
134 be a `bool`. If ``ops`` is `sqlachemy.sql`, it will be a boolean
135 column expression.
136 """
137 return (
138 (self.end is None or other.begin is None or self.end > other.begin)
139 and (self.begin is None or other.end is None or other.end > self.begin)
140 )
142 def intersection(*args: Timespan) -> Optional[Timespan]:
143 """Return a new `Timespan` that is contained by all of the given ones.
145 Parameters
146 ----------
147 *args
148 All positional arguments are `Timespan` instances.
150 Returns
151 -------
152 intersection : `Timespan` or `None`
153 The intersection timespan, or `None`, if there is no intersection
154 or no arguments.
155 """
156 if len(args) == 0:
157 return None
158 elif len(args) == 1:
159 return args[0]
160 else:
161 begins = [ts.begin for ts in args if ts.begin is not None]
162 ends = [ts.end for ts in args if ts.end is not None]
163 if not begins:
164 begin = None
165 elif len(begins) == 1:
166 begin = begins[0]
167 else:
168 begin = max(*begins)
169 if not ends:
170 end = None
171 elif len(ends) == 1:
172 end = ends[0]
173 else:
174 end = min(*ends) if ends else None
175 if begin is not None and end is not None and begin >= end:
176 return None
177 return Timespan(begin=begin, end=end)
179 def difference(self, other: Timespan) -> Iterator[Timespan]:
180 """Return the one or two timespans that cover the interval(s) that are
181 in ``self`` but not ``other``.
183 This is implemented as an iterator because the result may be zero, one,
184 or two `Timespan` objects, depending on the relationship between the
185 operands.
187 Parameters
188 ----------
189 other : `Timespan`
190 Timespan to subtract.
192 Yields
193 ------
194 result : `Timespan`
195 A `Timespan` that is contained by ``self`` but does not overlap
196 ``other``.
197 """
198 if other.begin is not None:
199 if self.begin is None or self.begin < other.begin:
200 if self.end is not None and self.end < other.begin:
201 yield self
202 else:
203 yield Timespan(begin=self.begin, end=other.begin)
204 if other.end is not None:
205 if self.end is None or self.end > other.end:
206 if self.begin is not None and self.begin > other.end:
207 yield self
208 else:
209 yield Timespan(begin=other.end, end=self.end)
212_S = TypeVar("_S", bound="TimespanDatabaseRepresentation")
215class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation):
216 """An interface that encapsulates how timespans are represented in a
217 database engine.
219 Most of this class's interface is comprised of classmethods. Instances
220 can be constructed via the `fromSelectable` method as a way to include
221 timespan overlap operations in query JOIN or WHERE clauses.
222 """
224 NAME: ClassVar[str] = "timespan"
225 """Base name for all timespan fields in the database (`str`).
227 Actual field names may be derived from this, rather than exactly this.
228 """
230 Compound: ClassVar[Type[TimespanDatabaseRepresentation]]
231 """A concrete subclass of `TimespanDatabaseRepresentation` that simply
232 uses two separate fields for the begin (inclusive) and end (excusive)
233 endpoints.
235 This implementation should be compatibly with any SQL database, and should
236 generally be used when a database-specific implementation is not available.
237 """
239 __slots__ = ()
241 @classmethod
242 @abstractmethod
243 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
244 """Make one or more `ddl.FieldSpec` objects that reflect the fields
245 that must be added to a table for this representation.
247 Parameters
248 ----------
249 nullable : `bool`
250 If `True`, the timespan is permitted to be logically ``NULL``
251 (mapped to `None` in Python), though the correspoding value(s) in
252 the database are implementation-defined. Nullable timespan fields
253 default to NULL, while others default to (-∞, ∞).
254 **kwargs
255 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
256 for all fields; implementations only provide the ``name``,
257 ``dtype``, and ``default`` arguments themselves.
259 Returns
260 -------
261 specs : `tuple` [ `ddl.FieldSpec` ]
262 Field specification objects; length of the tuple is
263 subclass-dependent, but is guaranteed to match the length of the
264 return values of `getFieldNames` and `update`.
265 """
266 raise NotImplementedError()
268 @classmethod
269 @abstractmethod
270 def getFieldNames(cls) -> Tuple[str, ...]:
271 """Return the actual field names used by this representation.
273 Returns
274 -------
275 names : `tuple` [ `str` ]
276 Field name(s). Guaranteed to be the same as the names of the field
277 specifications returned by `makeFieldSpecs`.
278 """
279 raise NotImplementedError()
281 @classmethod
282 @abstractmethod
283 def update(cls, timespan: Optional[Timespan], *,
284 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
285 """Add a `Timespan` to a dictionary that represents a database row
286 in this representation.
288 Parameters
289 ----------
290 timespan : `Timespan`, optional
291 A concrete timespan.
292 result : `dict` [ `str`, `Any` ], optional
293 A dictionary representing a database row that fields should be
294 added to, or `None` to create and return a new one.
296 Returns
297 -------
298 result : `dict` [ `str`, `Any` ]
299 A dictionary containing this representation of a timespan. Exactly
300 the `dict` passed as ``result`` if that is not `None`.
301 """
302 raise NotImplementedError()
304 @classmethod
305 @abstractmethod
306 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
307 """Extract a `Timespan` instance from a dictionary that represents a
308 database row in this representation.
310 Parameters
311 ----------
312 mapping : `Mapping` [ `str`, `Any` ]
313 A dictionary representing a database row containing a `Timespan`
314 in this representation. Should have key(s) equal to the return
315 value of `getFieldNames`.
317 Returns
318 -------
319 timespan : `Timespan` or `None`
320 Python representation of the timespan.
321 """
322 raise NotImplementedError()
324 @classmethod
325 def hasExclusionConstraint(cls) -> bool:
326 """Return `True` if this representation supports exclusion constraints.
328 Returns
329 -------
330 supported : `bool`
331 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
332 includes the fields of this representation is allowed.
333 """
334 return False
336 @abstractmethod
337 def isNull(self) -> sqlalchemy.sql.ColumnElement:
338 """Return a SQLAlchemy expression that tests whether this timespan is
339 logically ``NULL``.
341 Returns
342 -------
343 isnull : `sqlalchemy.sql.ColumnElement`
344 A boolean SQLAlchemy expression object.
345 """
346 raise NotImplementedError()
348 @abstractmethod
349 def overlaps(self: _S, other: Union[Timespan, _S]) -> sqlalchemy.sql.ColumnElement:
350 """Return a SQLAlchemy expression representing an overlap operation on
351 timespans.
353 Parameters
354 ----------
355 other : `Timespan` or `TimespanDatabaseRepresentation`
356 The timespan to overlap ``self`` with; either a Python `Timespan`
357 literal or an instance of the same `TimespanDatabaseRepresentation`
358 as ``self``, representing a timespan in some other table or query
359 within the same database.
361 Returns
362 -------
363 overlap : `sqlalchemy.sql.ColumnElement`
364 A boolean SQLAlchemy expression object.
365 """
366 raise NotImplementedError()
369class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation):
370 """An implementation of `TimespanDatabaseRepresentation` that simply stores
371 the endpoints in two separate fields.
373 This type should generally be accessed via
374 `TimespanDatabaseRepresentation.Compound`, and should be constructed only
375 via the `fromSelectable` method.
377 Parameters
378 ----------
379 begin : `sqlalchemy.sql.ColumnElement`
380 SQLAlchemy object representing the begin (inclusive) endpoint.
381 end : `sqlalchemy.sql.ColumnElement`
382 SQLAlchemy object representing the end (exclusive) endpoint.
384 Notes
385 -----
386 ``NULL`` timespans are represented by having both fields set to ``NULL``;
387 setting only one to ``NULL`` is considered a corrupted state that should
388 only be possible if this interface is circumvented. `Timespan` instances
389 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
390 are set to fields mapped to the minimum and maximum value constants used
391 by our integer-time mapping.
392 """
393 def __init__(self, begin: sqlalchemy.sql.ColumnElement, end: sqlalchemy.sql.ColumnElement):
394 self.begin = begin
395 self.end = end
397 __slots__ = ("begin", "end")
399 @classmethod
400 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
401 # Docstring inherited.
402 return (
403 ddl.FieldSpec(
404 f"{cls.NAME}_begin", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
405 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(EPOCH)))),
406 **kwargs,
407 ),
408 ddl.FieldSpec(
409 f"{cls.NAME}_end", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
410 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(MAX_TIME)))),
411 **kwargs,
412 ),
413 )
415 @classmethod
416 def getFieldNames(cls) -> Tuple[str, ...]:
417 # Docstring inherited.
418 return (f"{cls.NAME}_begin", f"{cls.NAME}_end")
420 @classmethod
421 def update(cls, timespan: Optional[Timespan], *,
422 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
423 # Docstring inherited.
424 if result is None:
425 result = {}
426 if timespan is None:
427 begin = None
428 end = None
429 else:
430 # These comparisons can trigger UTC -> TAI conversions that
431 # can result in warnings for simulated data from the future
432 with warnings.catch_warnings():
433 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
434 if erfa is not None:
435 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
436 if timespan.begin is None or timespan.begin < EPOCH:
437 begin = EPOCH
438 else:
439 begin = timespan.begin
440 # MAX_TIME is first in comparison to force a conversion
441 # from the supplied time scale to TAI rather than
442 # forcing MAX_TIME to be continually converted to
443 # the target time scale (which triggers warnings to UTC)
444 if timespan.end is None or MAX_TIME <= timespan.end:
445 end = MAX_TIME
446 else:
447 end = timespan.end
448 result[f"{cls.NAME}_begin"] = begin
449 result[f"{cls.NAME}_end"] = end
450 return result
452 @classmethod
453 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
454 # Docstring inherited.
455 begin = mapping[f"{cls.NAME}_begin"]
456 end = mapping[f"{cls.NAME}_end"]
457 if begin is None:
458 if end is not None:
459 raise RuntimeError(
460 f"Corrupted timespan extracted: begin is NULL, but end is {end}."
461 )
462 return None
463 elif end is None:
464 raise RuntimeError(
465 f"Corrupted timespan extracted: end is NULL, but begin is {begin}."
466 )
467 if times_equal(begin, EPOCH):
468 begin = None
469 elif begin < EPOCH:
470 raise RuntimeError(
471 f"Corrupted timespan extracted: begin ({begin}) is before {EPOCH}."
472 )
473 if times_equal(end, MAX_TIME):
474 end = None
475 elif end > MAX_TIME:
476 raise RuntimeError(
477 f"Corrupted timespan extracted: end ({end}) is after {MAX_TIME}."
478 )
479 return Timespan(begin=begin, end=end)
481 @classmethod
482 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause) -> _CompoundTimespanDatabaseRepresentation:
483 # Docstring inherited.
484 return cls(begin=selectable.columns[f"{cls.NAME}_begin"],
485 end=selectable.columns[f"{cls.NAME}_end"])
487 def isNull(self) -> sqlalchemy.sql.ColumnElement:
488 # Docstring inherited.
489 return self.begin.is_(None)
491 def overlaps(self, other: Union[Timespan, _CompoundTimespanDatabaseRepresentation]
492 ) -> sqlalchemy.sql.ColumnElement:
493 # Docstring inherited.
494 if isinstance(other, Timespan):
495 begin = EPOCH if other.begin is None else other.begin
496 end = MAX_TIME if other.end is None else other.end
497 elif isinstance(other, _CompoundTimespanDatabaseRepresentation):
498 begin = other.begin
499 end = other.end
500 else:
501 raise TypeError(f"Unexpected argument to overlaps: {other!r}.")
502 return sqlalchemy.sql.and_(self.end > begin, end > self.begin)
505TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation