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

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