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