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 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}, "
71 if self.end is None:
72 tail = "∞)"
73 else:
74 tail = f"{self.end})"
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 overlaps(self, other: Timespan) -> Any:
86 """Test whether this timespan overlaps another.
88 Parameters
89 ----------
90 other : `Timespan`
91 Another timespan whose begin and end values can be compared with
92 those of ``self`` with the ``>=`` operator, yielding values
93 that can be passed to ``ops.or_`` and/or ``ops.and_``.
95 Returns
96 -------
97 overlaps : `Any`
98 The result of the overlap. When ``ops`` is `operator`, this will
99 be a `bool`. If ``ops`` is `sqlachemy.sql`, it will be a boolean
100 column expression.
101 """
102 return (
103 (self.end is None or other.begin is None or self.end > other.begin)
104 and (self.begin is None or other.end is None or other.end > self.begin)
105 )
107 def intersection(*args: Timespan) -> Optional[Timespan]:
108 """Return a new `Timespan` that is contained by all of the given ones.
110 Parameters
111 ----------
112 *args
113 All positional arguments are `Timespan` instances.
115 Returns
116 -------
117 intersection : `Timespan` or `None`
118 The intersection timespan, or `None`, if there is no intersection
119 or no arguments.
120 """
121 if len(args) == 0:
122 return None
123 elif len(args) == 1:
124 return args[0]
125 else:
126 begins = [ts.begin for ts in args if ts.begin is not None]
127 ends = [ts.end for ts in args if ts.end is not None]
128 if not begins:
129 begin = None
130 elif len(begins) == 1:
131 begin = begins[0]
132 else:
133 begin = max(*begins)
134 if not ends:
135 end = None
136 elif len(ends) == 1:
137 end = ends[0]
138 else:
139 end = min(*ends) if ends else None
140 if begin is not None and end is not None and begin >= end:
141 return None
142 return Timespan(begin=begin, end=end)
144 def difference(self, other: Timespan) -> Iterator[Timespan]:
145 """Return the one or two timespans that cover the interval(s) that are
146 in ``self`` but not ``other``.
148 This is implemented as an iterator because the result may be zero, one,
149 or two `Timespan` objects, depending on the relationship between the
150 operands.
152 Parameters
153 ----------
154 other : `Timespan`
155 Timespan to subtract.
157 Yields
158 ------
159 result : `Timespan`
160 A `Timespan` that is contained by ``self`` but does not overlap
161 ``other``.
162 """
163 if other.begin is not None:
164 if self.begin is None or self.begin < other.begin:
165 if self.end is not None and self.end < other.begin:
166 yield self
167 else:
168 yield Timespan(begin=self.begin, end=other.begin)
169 if other.end is not None:
170 if self.end is None or self.end > other.end:
171 if self.begin is not None and self.begin > other.end:
172 yield self
173 else:
174 yield Timespan(begin=other.end, end=self.end)
177class DatabaseTimespanRepresentation(ABC):
178 """An interface that encapsulates how timespans are represented in a
179 database engine.
181 Most of this class's interface is comprised of classmethods. Instances
182 can be constructed via the `fromSelectable` method as a way to include
183 timespan overlap operations in query JOIN or WHERE clauses.
184 """
186 NAME: ClassVar[str] = "timespan"
187 """Base name for all timespan fields in the database (`str`).
189 Actual field names may be derived from this, rather than exactly this.
190 """
192 Compound: ClassVar[Type[DatabaseTimespanRepresentation]]
193 """A concrete subclass of `DatabaseTimespanRepresentation` that simply
194 uses two separate fields for the begin (inclusive) and end (excusive)
195 endpoints.
197 This implementation should be compatibly with any SQL database, and should
198 generally be used when a database-specific implementation is not available.
199 """
201 __slots__ = ()
203 @classmethod
204 @abstractmethod
205 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
206 """Make one or more `ddl.FieldSpec` objects that reflect the fields
207 that must be added to a table for this representation.
209 Parameters
210 ----------
211 nullable : `bool`
212 If `True`, the timespan is permitted to be logically ``NULL``
213 (mapped to `None` in Python), though the correspoding value(s) in
214 the database are implementation-defined. Nullable timespan fields
215 default to NULL, while others default to (-∞, ∞).
216 **kwargs
217 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor
218 for all fields; implementations only provide the ``name``,
219 ``dtype``, and ``default`` arguments themselves.
221 Returns
222 -------
223 specs : `tuple` [ `ddl.FieldSpec` ]
224 Field specification objects; length of the tuple is
225 subclass-dependent, but is guaranteed to match the length of the
226 return values of `getFieldNames` and `update`.
227 """
228 raise NotImplementedError()
230 @classmethod
231 @abstractmethod
232 def getFieldNames(cls) -> Tuple[str, ...]:
233 """Return the actual field names used by this representation.
235 Returns
236 -------
237 names : `tuple` [ `str` ]
238 Field name(s). Guaranteed to be the same as the names of the field
239 specifications returned by `makeFieldSpecs`.
240 """
241 raise NotImplementedError()
243 @classmethod
244 @abstractmethod
245 def update(cls, timespan: Optional[Timespan], *,
246 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
247 """Add a `Timespan` to a dictionary that represents a database row
248 in this representation.
250 Parameters
251 ----------
252 timespan : `Timespan`, optional
253 A concrete timespan.
254 result : `dict` [ `str`, `Any` ], optional
255 A dictionary representing a database row that fields should be
256 added to, or `None` to create and return a new one.
258 Returns
259 -------
260 result : `dict` [ `str`, `Any` ]
261 A dictionary containing this representation of a timespan. Exactly
262 the `dict` passed as ``result`` if that is not `None`.
263 """
264 raise NotImplementedError()
266 @classmethod
267 @abstractmethod
268 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
269 """Extract a `Timespan` instance from a dictionary that represents a
270 database row in this representation.
272 Parameters
273 ----------
274 mapping : `Mapping` [ `str`, `Any` ]
275 A dictionary representing a database row containing a `Timespan`
276 in this representation. Should have key(s) equal to the return
277 value of `getFieldNames`.
279 Returns
280 -------
281 timespan : `Timespan` or `None`
282 Python representation of the timespan.
283 """
284 raise NotImplementedError()
286 @classmethod
287 def hasExclusionConstraint(cls) -> bool:
288 """Return `True` if this representation supports exclusion constraints.
290 Returns
291 -------
292 supported : `bool`
293 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that
294 includes the fields of this representation is allowed.
295 """
296 return False
298 @classmethod
299 @abstractmethod
300 def fromSelectable(cls: Type[S], selectable: sqlalchemy.sql.FromClause) -> S:
301 """Construct an instance of this class that proxies the columns of
302 this representation in a table or SELECT query.
304 Parameters
305 ----------
306 selectable : `sqlalchemy.sql.FromClause`
307 SQLAlchemy object representing a table or SELECT query that has
308 columns in this representation.
310 Returns
311 -------
312 instance : `DatabaseTimespanRepresentation`
313 An instance of this representation subclass.
314 """
315 raise NotImplementedError()
317 @abstractmethod
318 def isNull(self) -> sqlalchemy.sql.ColumnElement:
319 """Return a SQLAlchemy expression that tests whether this timespan is
320 logically ``NULL``.
322 Returns
323 -------
324 isnull : `sqlalchemy.sql.ColumnElement`
325 A boolean SQLAlchemy expression object.
326 """
327 raise NotImplementedError()
329 @abstractmethod
330 def overlaps(self: S, other: Union[Timespan, S]) -> sqlalchemy.sql.ColumnElement:
331 """Return a SQLAlchemy expression representing an overlap operation on
332 timespans.
334 Parameters
335 ----------
336 other : `Timespan` or `DatabaseTimespanRepresentation`
337 The timespan to overlap ``self`` with; either a Python `Timespan`
338 literal or an instance of the same `DatabaseTimespanRepresentation`
339 as ``self``, representing a timespan in some other table or query
340 within the same database.
342 Returns
343 -------
344 overlap : `sqlalchemy.sql.ColumnElement`
345 A boolean SQLAlchemy expression object.
346 """
347 raise NotImplementedError()
350class _CompoundDatabaseTimespanRepresentation(DatabaseTimespanRepresentation):
351 """An implementation of `DatabaseTimespanRepresentation` that simply stores
352 the endpoints in two separate fields.
354 This type should generally be accessed via
355 `DatabaseTimespanRepresentation.Compound`, and should be constructed only
356 via the `fromSelectable` method.
358 Parameters
359 ----------
360 begin : `sqlalchemy.sql.ColumnElement`
361 SQLAlchemy object representing the begin (inclusive) endpoint.
362 end : `sqlalchemy.sql.ColumnElement`
363 SQLAlchemy object representing the end (exclusive) endpoint.
365 Notes
366 -----
367 ``NULL`` timespans are represented by having both fields set to ``NULL``;
368 setting only one to ``NULL`` is considered a corrupted state that should
369 only be possible if this interface is circumvented. `Timespan` instances
370 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None`
371 are set to fields mapped to the minimum and maximum value constants used
372 by our integer-time mapping.
373 """
374 def __init__(self, begin: sqlalchemy.sql.ColumnElement, end: sqlalchemy.sql.ColumnElement):
375 self.begin = begin
376 self.end = end
378 __slots__ = ("begin", "end")
380 @classmethod
381 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]:
382 # Docstring inherited.
383 return (
384 ddl.FieldSpec(
385 f"{cls.NAME}_begin", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
386 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(EPOCH)))),
387 **kwargs,
388 ),
389 ddl.FieldSpec(
390 f"{cls.NAME}_end", dtype=ddl.AstropyTimeNsecTai, nullable=nullable,
391 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(MAX_TIME)))),
392 **kwargs,
393 ),
394 )
396 @classmethod
397 def getFieldNames(cls) -> Tuple[str, ...]:
398 # Docstring inherited.
399 return (f"{cls.NAME}_begin", f"{cls.NAME}_end")
401 @classmethod
402 def update(cls, timespan: Optional[Timespan], *,
403 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
404 # Docstring inherited.
405 if result is None:
406 result = {}
407 if timespan is None:
408 begin = None
409 end = None
410 else:
411 if timespan.begin is None or timespan.begin < EPOCH:
412 begin = EPOCH
413 else:
414 begin = timespan.begin
415 if timespan.end is None or timespan.end > MAX_TIME:
416 end = MAX_TIME
417 else:
418 end = timespan.end
419 result[f"{cls.NAME}_begin"] = begin
420 result[f"{cls.NAME}_end"] = end
421 return result
423 @classmethod
424 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]:
425 # Docstring inherited.
426 begin = mapping[f"{cls.NAME}_begin"]
427 end = mapping[f"{cls.NAME}_end"]
428 if begin is None:
429 if end is not None:
430 raise RuntimeError(
431 f"Corrupted timespan extracted: begin is NULL, but end is {end}."
432 )
433 return None
434 elif end is None:
435 raise RuntimeError(
436 f"Corrupted timespan extracted: end is NULL, but begin is {begin}."
437 )
438 if times_equal(begin, EPOCH):
439 begin = None
440 elif begin < EPOCH:
441 raise RuntimeError(
442 f"Corrupted timespan extracted: begin ({begin}) is before {EPOCH}."
443 )
444 if times_equal(end, MAX_TIME):
445 end = None
446 elif end > MAX_TIME:
447 raise RuntimeError(
448 f"Corrupted timespan extracted: end ({end}) is after {MAX_TIME}."
449 )
450 return Timespan(begin=begin, end=end)
452 @classmethod
453 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause) -> _CompoundDatabaseTimespanRepresentation:
454 # Docstring inherited.
455 return cls(begin=selectable.columns[f"{cls.NAME}_begin"],
456 end=selectable.columns[f"{cls.NAME}_end"])
458 def isNull(self) -> sqlalchemy.sql.ColumnElement:
459 # Docstring inherited.
460 return self.begin.is_(None)
462 def overlaps(self, other: Union[Timespan, _CompoundDatabaseTimespanRepresentation]
463 ) -> sqlalchemy.sql.ColumnElement:
464 # Docstring inherited.
465 if isinstance(other, Timespan):
466 begin = EPOCH if other.begin is None else other.begin
467 end = MAX_TIME if other.end is None else other.end
468 elif isinstance(other, _CompoundDatabaseTimespanRepresentation):
469 begin = other.begin
470 end = other.end
471 else:
472 raise TypeError(f"Unexpected argument to overlaps: {other!r}.")
473 return sqlalchemy.sql.and_(self.end > begin, end > self.begin)
476DatabaseTimespanRepresentation.Compound = _CompoundDatabaseTimespanRepresentation