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 "DatabaseTimespanRepresentation"
26)
28from abc import ABC, 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
36from . import ddl
37from .time_utils import astropy_to_nsec, EPOCH, MAX_TIME, times_equal
40S = TypeVar("S", bound="DatabaseTimespanRepresentation")
43class Timespan(NamedTuple):
44 """A 2-element named tuple for time intervals.
46 Parameters
47 ----------
48 begin : ``Timespan``
49 Minimum timestamp in the interval (inclusive). `None` is interpreted
50 as -infinity.
51 end : ``Timespan``
52 Maximum timestamp in the interval (exclusive). `None` is interpreted
53 as +infinity.
54 """
56 begin: Optional[astropy.time.Time]
57 """Minimum timestamp in the interval (inclusive).
59 `None` should be interpreted as -infinity.
60 """
62 end: Optional[astropy.time.Time]
63 """Maximum timestamp in the interval (exclusive).
65 `None` should be interpreted as +infinity.
66 """
68 def __str__(self) -> str:
69 # Trap dubious year warnings in case we have timespans from
70 # simulated data in the future
71 with warnings.catch_warnings():
72 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
73 if self.begin is None:
74 head = "(-∞, "
75 else:
76 head = f"[{self.begin.tai.isot}, "
77 if self.end is None:
78 tail = "∞)"
79 else:
80 tail = f"{self.end.tai.isot})"
81 return head + tail
83 def __repr__(self) -> str:
84 # astropy.time.Time doesn't have an eval-friendly __repr__, so we
85 # simulate our own here to make Timespan's __repr__ eval-friendly.
86 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')"
87 begin = tmpl.format(t=self.begin) if self.begin is not None else None
88 end = tmpl.format(t=self.end) if self.end is not None else None
89 return f"Timespan(begin={begin}, end={end})"
91 def __eq__(self, other: Any) -> bool:
92 # Include some fuzziness in equality because round tripping
93 # can introduce some drift at the picosecond level
94 # Butler is okay wih nanosecond precision
95 if not isinstance(other, type(self)):
96 return False
98 def compare_time(t1: Optional[astropy.time.Time], t2: Optional[astropy.time.Time]) -> bool:
99 if t1 is None and t2 is None:
100 return True
101 if t1 is None or t2 is None:
102 return False
104 return times_equal(t1, t2)
106 result = compare_time(self.begin, other.begin) and compare_time(self.end, other.end)
107 return result
109 def __ne__(self, other: Any) -> bool:
110 # Need to override the explicit parent class implementation
111 return not self.__eq__(other)
113 def overlaps(self, other: Timespan) -> Any:
114 """Test whether this timespan overlaps another.
116 Parameters
117 ----------
118 other : `Timespan`
119 Another timespan whose begin and end values can be compared with
120 those of ``self`` with the ``>=`` operator, yielding values
121 that can be passed to ``ops.or_`` and/or ``ops.and_``.
123 Returns
124 -------
125 overlaps : `Any`
126 The result of the overlap. When ``ops`` is `operator`, this will
127 be a `bool`. If ``ops`` is `sqlachemy.sql`, it will be a boolean
128 column expression.
129 """
130 return (
131 (self.end is None or other.begin is None or self.end > other.begin)
132 and (self.begin is None or other.end is None or other.end > self.begin)
133 )
135 def intersection(*args: Timespan) -> Optional[Timespan]:
136 """Return a new `Timespan` that is contained by all of the given ones.
138 Parameters
139 ----------
140 *args
141 All positional arguments are `Timespan` instances.
143 Returns
144 -------
145 intersection : `Timespan` or `None`
146 The intersection timespan, or `None`, if there is no intersection
147 or no arguments.
148 """
149 if len(args) == 0:
150 return None
151 elif len(args) == 1:
152 return args[0]
153 else:
154 begins = [ts.begin for ts in args if ts.begin is not None]
155 ends = [ts.end for ts in args if ts.end is not None]
156 if not begins:
157 begin = None
158 elif len(begins) == 1:
159 begin = begins[0]
160 else:
161 begin = max(*begins)
162 if not ends:
163 end = None
164 elif len(ends) == 1:
165 end = ends[0]
166 else:
167 end = min(*ends) if ends else None
168 if begin is not None and end is not None and begin >= end:
169 return None
170 return Timespan(begin=begin, end=end)
172 def difference(self, other: Timespan) -> Iterator[Timespan]:
173 """Return the one or two timespans that cover the interval(s) that are
174 in ``self`` but not ``other``.
176 This is implemented as an iterator because the result may be zero, one,
177 or two `Timespan` objects, depending on the relationship between the
178 operands.
180 Parameters
181 ----------
182 other : `Timespan`
183 Timespan to subtract.
185 Yields
186 ------
187 result : `Timespan`
188 A `Timespan` that is contained by ``self`` but does not overlap
189 ``other``.
190 """
191 if other.begin is not None:
192 if self.begin is None or self.begin < other.begin:
193 if self.end is not None and self.end < other.begin:
194 yield self
195 else:
196 yield Timespan(begin=self.begin, end=other.begin)
197 if other.end is not None:
198 if self.end is None or self.end > other.end:
199 if self.begin is not None and self.begin > other.end:
200 yield self
201 else:
202 yield Timespan(begin=other.end, end=self.end)
205class DatabaseTimespanRepresentation(ABC):
206 """An interface that encapsulates how timespans are represented in a
207 database engine.
209 Most of this class's interface is comprised of classmethods. Instances
210 can be constructed via the `fromSelectable` method as a way to include
211 timespan overlap operations in query JOIN or WHERE clauses.
212 """
214 NAME: ClassVar[str] = "timespan"
215 """Base name for all timespan fields in the database (`str`).
217 Actual field names may be derived from this, rather than exactly this.
218 """
220 Compound: ClassVar[Type[DatabaseTimespanRepresentation]]
221 """A concrete subclass of `DatabaseTimespanRepresentation` that simply
222 uses two separate fields for the begin (inclusive) and end (excusive)
223 endpoints.
225 This implementation should be compatibly with any SQL database, and should
226 generally be used when a database-specific implementation is not available.
227 """
229 __slots__ = ()
231 @classmethod
232 @abstractmethod
233 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
234 """Make one or more `ddl.FieldSpec` objects that reflect the fields
235 that must be added to a table for this representation.
237 Parameters
238 ----------
239 nullable : `bool`
240 If `True`, the timespan is permitted to be logically ``NULL``
241 (mapped to `None` in Python), though the correspoding value(s) in
242 the database are implementation-defined. Nullable timespan fields
243 default to NULL, while others default to (-∞, ∞).
244 **kwargs
245 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
246 for all fields; implementations only provide the ``name``,
247 ``dtype``, and ``default`` arguments themselves.
249 Returns
250 -------
251 specs : `tuple` [ `ddl.FieldSpec` ]
252 Field specification objects; length of the tuple is
253 subclass-dependent, but is guaranteed to match the length of the
254 return values of `getFieldNames` and `update`.
255 """
256 raise NotImplementedError()
258 @classmethod
259 @abstractmethod
260 def getFieldNames(cls) -> Tuple[str, ...]:
261 """Return the actual field names used by this representation.
263 Returns
264 -------
265 names : `tuple` [ `str` ]
266 Field name(s). Guaranteed to be the same as the names of the field
267 specifications returned by `makeFieldSpecs`.
268 """
269 raise NotImplementedError()
271 @classmethod
272 @abstractmethod
273 def update(cls, timespan: Optional[Timespan], *,
274 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
275 """Add a `Timespan` to a dictionary that represents a database row
276 in this representation.
278 Parameters
279 ----------
280 timespan : `Timespan`, optional
281 A concrete timespan.
282 result : `dict` [ `str`, `Any` ], optional
283 A dictionary representing a database row that fields should be
284 added to, or `None` to create and return a new one.
286 Returns
287 -------
288 result : `dict` [ `str`, `Any` ]
289 A dictionary containing this representation of a timespan. Exactly
290 the `dict` passed as ``result`` if that is not `None`.
291 """
292 raise NotImplementedError()
294 @classmethod
295 @abstractmethod
296 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
297 """Extract a `Timespan` instance from a dictionary that represents a
298 database row in this representation.
300 Parameters
301 ----------
302 mapping : `Mapping` [ `str`, `Any` ]
303 A dictionary representing a database row containing a `Timespan`
304 in this representation. Should have key(s) equal to the return
305 value of `getFieldNames`.
307 Returns
308 -------
309 timespan : `Timespan` or `None`
310 Python representation of the timespan.
311 """
312 raise NotImplementedError()
314 @classmethod
315 def hasExclusionConstraint(cls) -> bool:
316 """Return `True` if this representation supports exclusion constraints.
318 Returns
319 -------
320 supported : `bool`
321 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
322 includes the fields of this representation is allowed.
323 """
324 return False
326 @classmethod
327 @abstractmethod
328 def fromSelectable(cls: Type[S], selectable: sqlalchemy.sql.FromClause) -> S:
329 """Construct an instance of this class that proxies the columns of
330 this representation in a table or SELECT query.
332 Parameters
333 ----------
334 selectable : `sqlalchemy.sql.FromClause`
335 SQLAlchemy object representing a table or SELECT query that has
336 columns in this representation.
338 Returns
339 -------
340 instance : `DatabaseTimespanRepresentation`
341 An instance of this representation subclass.
342 """
343 raise NotImplementedError()
345 @abstractmethod
346 def isNull(self) -> sqlalchemy.sql.ColumnElement:
347 """Return a SQLAlchemy expression that tests whether this timespan is
348 logically ``NULL``.
350 Returns
351 -------
352 isnull : `sqlalchemy.sql.ColumnElement`
353 A boolean SQLAlchemy expression object.
354 """
355 raise NotImplementedError()
357 @abstractmethod
358 def overlaps(self: S, other: Union[Timespan, S]) -> sqlalchemy.sql.ColumnElement:
359 """Return a SQLAlchemy expression representing an overlap operation on
360 timespans.
362 Parameters
363 ----------
364 other : `Timespan` or `DatabaseTimespanRepresentation`
365 The timespan to overlap ``self`` with; either a Python `Timespan`
366 literal or an instance of the same `DatabaseTimespanRepresentation`
367 as ``self``, representing a timespan in some other table or query
368 within the same database.
370 Returns
371 -------
372 overlap : `sqlalchemy.sql.ColumnElement`
373 A boolean SQLAlchemy expression object.
374 """
375 raise NotImplementedError()
378class _CompoundDatabaseTimespanRepresentation(DatabaseTimespanRepresentation):
379 """An implementation of `DatabaseTimespanRepresentation` that simply stores
380 the endpoints in two separate fields.
382 This type should generally be accessed via
383 `DatabaseTimespanRepresentation.Compound`, and should be constructed only
384 via the `fromSelectable` method.
386 Parameters
387 ----------
388 begin : `sqlalchemy.sql.ColumnElement`
389 SQLAlchemy object representing the begin (inclusive) endpoint.
390 end : `sqlalchemy.sql.ColumnElement`
391 SQLAlchemy object representing the end (exclusive) endpoint.
393 Notes
394 -----
395 ``NULL`` timespans are represented by having both fields set to ``NULL``;
396 setting only one to ``NULL`` is considered a corrupted state that should
397 only be possible if this interface is circumvented. `Timespan` instances
398 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
399 are set to fields mapped to the minimum and maximum value constants used
400 by our integer-time mapping.
401 """
402 def __init__(self, begin: sqlalchemy.sql.ColumnElement, end: sqlalchemy.sql.ColumnElement):
403 self.begin = begin
404 self.end = end
406 __slots__ = ("begin", "end")
408 @classmethod
409 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
410 # Docstring inherited.
411 return (
412 ddl.FieldSpec(
413 f"{cls.NAME}_begin", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
414 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(EPOCH)))),
415 **kwargs,
416 ),
417 ddl.FieldSpec(
418 f"{cls.NAME}_end", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
419 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(MAX_TIME)))),
420 **kwargs,
421 ),
422 )
424 @classmethod
425 def getFieldNames(cls) -> Tuple[str, ...]:
426 # Docstring inherited.
427 return (f"{cls.NAME}_begin", f"{cls.NAME}_end")
429 @classmethod
430 def update(cls, timespan: Optional[Timespan], *,
431 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
432 # Docstring inherited.
433 if result is None:
434 result = {}
435 if timespan is None:
436 begin = None
437 end = None
438 else:
439 # These comparisons can trigger UTC -> TAI conversions that
440 # can result in warnings for simulated data from the future
441 with warnings.catch_warnings():
442 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
443 if timespan.begin is None or timespan.begin < EPOCH:
444 begin = EPOCH
445 else:
446 begin = timespan.begin
447 # MAX_TIME is first in comparison to force a conversion
448 # from the supplied time scale to TAI rather than
449 # forcing MAX_TIME to be continually converted to
450 # the target time scale (which triggers warnings to UTC)
451 if timespan.end is None or MAX_TIME <= timespan.end:
452 end = MAX_TIME
453 else:
454 end = timespan.end
455 result[f"{cls.NAME}_begin"] = begin
456 result[f"{cls.NAME}_end"] = end
457 return result
459 @classmethod
460 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
461 # Docstring inherited.
462 begin = mapping[f"{cls.NAME}_begin"]
463 end = mapping[f"{cls.NAME}_end"]
464 if begin is None:
465 if end is not None:
466 raise RuntimeError(
467 f"Corrupted timespan extracted: begin is NULL, but end is {end}."
468 )
469 return None
470 elif end is None:
471 raise RuntimeError(
472 f"Corrupted timespan extracted: end is NULL, but begin is {begin}."
473 )
474 if times_equal(begin, EPOCH):
475 begin = None
476 elif begin < EPOCH:
477 raise RuntimeError(
478 f"Corrupted timespan extracted: begin ({begin}) is before {EPOCH}."
479 )
480 if times_equal(end, MAX_TIME):
481 end = None
482 elif end > MAX_TIME:
483 raise RuntimeError(
484 f"Corrupted timespan extracted: end ({end}) is after {MAX_TIME}."
485 )
486 return Timespan(begin=begin, end=end)
488 @classmethod
489 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause) -> _CompoundDatabaseTimespanRepresentation:
490 # Docstring inherited.
491 return cls(begin=selectable.columns[f"{cls.NAME}_begin"],
492 end=selectable.columns[f"{cls.NAME}_end"])
494 def isNull(self) -> sqlalchemy.sql.ColumnElement:
495 # Docstring inherited.
496 return self.begin.is_(None)
498 def overlaps(self, other: Union[Timespan, _CompoundDatabaseTimespanRepresentation]
499 ) -> sqlalchemy.sql.ColumnElement:
500 # Docstring inherited.
501 if isinstance(other, Timespan):
502 begin = EPOCH if other.begin is None else other.begin
503 end = MAX_TIME if other.end is None else other.end
504 elif isinstance(other, _CompoundDatabaseTimespanRepresentation):
505 begin = other.begin
506 end = other.end
507 else:
508 raise TypeError(f"Unexpected argument to overlaps: {other!r}.")
509 return sqlalchemy.sql.and_(self.end > begin, end > self.begin)
512DatabaseTimespanRepresentation.Compound = _CompoundDatabaseTimespanRepresentation