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

Shortcuts 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

268 statements  

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 

22 

23__all__ = ( 

24 "Timespan", 

25 "TimespanDatabaseRepresentation", 

26) 

27 

28from abc import abstractmethod 

29import enum 

30from typing import ( 

31 TYPE_CHECKING, 

32 Any, 

33 ClassVar, 

34 Dict, 

35 Generator, 

36 Iterator, 

37 List, 

38 Mapping, 

39 Optional, 

40 Tuple, 

41 Type, 

42 TypeVar, 

43 Union, 

44) 

45 

46import astropy.time 

47import astropy.utils.exceptions 

48import sqlalchemy 

49import warnings 

50 

51# As of astropy 4.2, the erfa interface is shipped independently and 

52# ErfaWarning is no longer an AstropyWarning 

53try: 

54 import erfa 

55except ImportError: 

56 erfa = None 

57 

58from . import ddl 

59from .time_utils import TimeConverter 

60from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace 

61from .utils import cached_getter 

62from .json import from_json_generic, to_json_generic 

63 

64if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true

65 from .dimensions import DimensionUniverse 

66 from ..registry import Registry 

67 

68 

69class _SpecialTimespanBound(enum.Enum): 

70 """Enumeration to provide a singleton value for empty timespan bounds. 

71 

72 This enum's only member should generally be accessed via the 

73 `Timespan.EMPTY` alias. 

74 """ 

75 

76 EMPTY = enum.auto() 

77 """The value used for both `Timespan.begin` and `Timespan.end` for empty 

78 Timespans that contain no points. 

79 """ 

80 

81 

82TimespanBound = Union[astropy.time.Time, _SpecialTimespanBound, None] 

83 

84 

85class Timespan: 

86 """A half-open time interval with nanosecond precision. 

87 

88 Parameters 

89 ---------- 

90 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None` 

91 Minimum timestamp in the interval (inclusive). `None` indicates that 

92 the timespan has no lower bound. `Timespan.EMPTY` indicates that the 

93 timespan contains no times; if this is used as either bound, the other 

94 bound is ignored. 

95 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None` 

96 Maximum timestamp in the interval (exclusive). `None` indicates that 

97 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY` 

98 creates an empty timespan. 

99 padInstantaneous : `bool`, optional 

100 If `True` (default) and ``begin == end`` *after discretization to 

101 integer nanoseconds*, extend ``end`` by one nanosecond to yield a 

102 finite-duration timespan. If `False`, ``begin == end`` evaluates to 

103 the empty timespan. 

104 _nsec : `tuple` of `int`, optional 

105 Integer nanosecond representation, for internal use by `Timespan` and 

106 `TimespanDatabaseRepresentation` implementation only. If provided, 

107 all other arguments are are ignored. 

108 

109 Raises 

110 ------ 

111 TypeError 

112 Raised if ``begin`` or ``end`` has a type other than 

113 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`. 

114 ValueError 

115 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times 

116 supported by this class. 

117 

118 Notes 

119 ----- 

120 Timespans are half-open intervals, i.e. ``[begin, end)``. 

121 

122 Any timespan with ``begin > end`` after nanosecond discretization 

123 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound 

124 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both 

125 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and 

126 contained by all other timespans (including itself). It is also disjoint 

127 with all timespans (including itself), and hence does not overlap any 

128 timespan - this is the only case where ``contains`` does not imply 

129 ``overlaps``. 

130 

131 Finite timespan bounds are represented internally as integer nanoseconds, 

132 and hence construction from `astropy.time.Time` (which has picosecond 

133 accuracy) can involve a loss of precision. This is of course 

134 deterministic, so any `astropy.time.Time` value is always mapped 

135 to the exact same timespan bound, but if ``padInstantaneous`` is `True`, 

136 timespans that are empty at full precision (``begin > end``, 

137 ``begin - end < 1ns``) may be finite after discretization. In all other 

138 cases, the relationships between full-precision timespans should be 

139 preserved even if the values are not. 

140 

141 The `astropy.time.Time` bounds that can be obtained after construction from 

142 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip 

143 exactly when used to construct other `Timespan` instances. 

144 """ 

145 

146 def __init__(self, begin: TimespanBound, end: TimespanBound, padInstantaneous: bool = True, 

147 _nsec: Optional[Tuple[int, int]] = None): 

148 converter = TimeConverter() 

149 if _nsec is None: 

150 begin_nsec: int 

151 if begin is None: 

152 begin_nsec = converter.min_nsec 

153 elif begin is self.EMPTY: 

154 begin_nsec = converter.max_nsec 

155 elif isinstance(begin, astropy.time.Time): 

156 begin_nsec = converter.astropy_to_nsec(begin) 

157 else: 

158 raise TypeError( 

159 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}." 

160 ) 

161 end_nsec: int 

162 if end is None: 

163 end_nsec = converter.max_nsec 

164 elif end is self.EMPTY: 

165 end_nsec = converter.min_nsec 

166 elif isinstance(end, astropy.time.Time): 

167 end_nsec = converter.astropy_to_nsec(end) 

168 else: 

169 raise TypeError( 

170 f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}." 

171 ) 

172 if begin_nsec == end_nsec: 

173 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec: 

174 with warnings.catch_warnings(): 

175 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

176 if erfa is not None: 

177 warnings.simplefilter("ignore", category=erfa.ErfaWarning) 

178 if begin is not None and begin < converter.epoch: 

179 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.") 

180 if end is not None and end > converter.max_time: 

181 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.") 

182 raise ValueError("Infinite instantaneous timespans are not supported.") 

183 elif padInstantaneous: 

184 end_nsec += 1 

185 if end_nsec == converter.max_nsec: 

186 raise ValueError( 

187 f"Cannot construct near-instantaneous timespan at {end}; " 

188 "within one ns of maximum time." 

189 ) 

190 _nsec = (begin_nsec, end_nsec) 

191 if _nsec[0] >= _nsec[1]: 

192 # Standardizing all empty timespans to the same underlying values 

193 # here simplifies all other operations (including interactions 

194 # with TimespanDatabaseRepresentation implementations). 

195 _nsec = (converter.max_nsec, converter.min_nsec) 

196 self._nsec = _nsec 

197 

198 __slots__ = ("_nsec", "_cached_begin", "_cached_end") 

199 

200 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

201 

202 @classmethod 

203 def makeEmpty(cls) -> Timespan: 

204 """Construct an empty timespan. 

205 

206 Returns 

207 ------- 

208 empty : `Timespan` 

209 A timespan that is contained by all timespans (including itself) 

210 and overlaps no other timespans (including itself). 

211 """ 

212 converter = TimeConverter() 

213 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec)) 

214 

215 @classmethod 

216 def fromInstant(cls, time: astropy.time.Time) -> Timespan: 

217 """Construct a timespan that approximates an instant in time. 

218 

219 This is done by constructing a minimum-possible (1 ns) duration 

220 timespan. 

221 

222 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``, 

223 but may be slightly more efficient. 

224 

225 Parameters 

226 ---------- 

227 time : `astropy.time.Time` 

228 Time to use for the lower bound. 

229 

230 Returns 

231 ------- 

232 instant : `Timespan` 

233 A ``[time, time + 1ns)`` timespan. 

234 """ 

235 converter = TimeConverter() 

236 nsec = converter.astropy_to_nsec(time) 

237 if nsec == converter.max_nsec - 1: 

238 raise ValueError( 

239 f"Cannot construct near-instantaneous timespan at {time}; " 

240 "within one ns of maximum time." 

241 ) 

242 return Timespan(None, None, _nsec=(nsec, nsec + 1)) 

243 

244 @property # type: ignore 

245 @cached_getter 

246 def begin(self) -> TimespanBound: 

247 """Minimum timestamp in the interval, inclusive. 

248 

249 If this bound is finite, this is an `astropy.time.Time` instance. 

250 If the timespan is unbounded from below, this is `None`. 

251 If the timespan is empty, this is the special value `Timespan.EMPTY`. 

252 """ 

253 if self.isEmpty(): 

254 return self.EMPTY 

255 elif self._nsec[0] == TimeConverter().min_nsec: 

256 return None 

257 else: 

258 return TimeConverter().nsec_to_astropy(self._nsec[0]) 

259 

260 @property # type: ignore 

261 @cached_getter 

262 def end(self) -> TimespanBound: 

263 """Maximum timestamp in the interval, exclusive. 

264 

265 If this bound is finite, this is an `astropy.time.Time` instance. 

266 If the timespan is unbounded from above, this is `None`. 

267 If the timespan is empty, this is the special value `Timespan.EMPTY`. 

268 """ 

269 if self.isEmpty(): 

270 return self.EMPTY 

271 elif self._nsec[1] == TimeConverter().max_nsec: 

272 return None 

273 else: 

274 return TimeConverter().nsec_to_astropy(self._nsec[1]) 

275 

276 def isEmpty(self) -> bool: 

277 """Test whether ``self`` is the empty timespan (`bool`).""" 

278 return self._nsec[0] >= self._nsec[1] 

279 

280 def __str__(self) -> str: 

281 if self.isEmpty(): 

282 return "(empty)" 

283 # Trap dubious year warnings in case we have timespans from 

284 # simulated data in the future 

285 with warnings.catch_warnings(): 

286 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

287 if erfa is not None: 

288 warnings.simplefilter("ignore", category=erfa.ErfaWarning) 

289 if self.begin is None: 

290 head = "(-∞, " 

291 else: 

292 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor" 

293 head = f"[{self.begin.tai.isot}, " 

294 if self.end is None: 

295 tail = "∞)" 

296 else: 

297 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor" 

298 tail = f"{self.end.tai.isot})" 

299 return head + tail 

300 

301 def __repr__(self) -> str: 

302 # astropy.time.Time doesn't have an eval-friendly __repr__, so we 

303 # simulate our own here to make Timespan's __repr__ eval-friendly. 

304 # Interestingly, enum.Enum has an eval-friendly __str__, but not an 

305 # eval-friendly __repr__. 

306 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')" 

307 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin) 

308 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end) 

309 return f"Timespan(begin={begin}, end={end})" 

310 

311 def __eq__(self, other: Any) -> bool: 

312 if not isinstance(other, Timespan): 

313 return False 

314 # Correctness of this simple implementation depends on __init__ 

315 # standardizing all empty timespans to a single value. 

316 return self._nsec == other._nsec 

317 

318 def __hash__(self) -> int: 

319 # Correctness of this simple implementation depends on __init__ 

320 # standardizing all empty timespans to a single value. 

321 return hash(self._nsec) 

322 

323 def __reduce__(self) -> tuple: 

324 return (Timespan, (None, None, False, self._nsec)) 

325 

326 def __lt__(self, other: Union[astropy.time.Time, Timespan]) -> bool: 

327 """Test if a Timespan's bounds are strictly less than the given time. 

328 

329 Parameters 

330 ---------- 

331 other : `Timespan` or `astropy.time.Time`. 

332 Timespan or instant in time to relate to ``self``. 

333 

334 Returns 

335 ------- 

336 less : `bool` 

337 The result of the less-than test. `False` if either operand is 

338 empty. 

339 """ 

340 # First term in each expression below is the "normal" one; the second 

341 # ensures correct behavior for empty timespans. It's important that 

342 # the second uses a strict inequality to make sure inf == inf isn't in 

343 # play, and it's okay for the second to use a strict inequality only 

344 # because we know non-empty Timespans have nonzero duration, and hence 

345 # the second term is never false for non-empty timespans unless the 

346 # first term is also false. 

347 if isinstance(other, astropy.time.Time): 

348 nsec = TimeConverter().astropy_to_nsec(other) 

349 return self._nsec[1] <= nsec and self._nsec[0] < nsec 

350 else: 

351 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1] 

352 

353 def __gt__(self, other: Union[astropy.time.Time, Timespan]) -> bool: 

354 """Test if a Timespan's bounds are strictly greater than given time. 

355 

356 Parameters 

357 ---------- 

358 other : `Timespan` or `astropy.time.Time`. 

359 Timespan or instant in time to relate to ``self``. 

360 

361 Returns 

362 ------- 

363 greater : `bool` 

364 The result of the greater-than test. `False` if either operand is 

365 empty. 

366 """ 

367 # First term in each expression below is the "normal" one; the second 

368 # ensures correct behavior for empty timespans. It's important that 

369 # the second uses a strict inequality to make sure inf == inf isn't in 

370 # play, and it's okay for the second to use a strict inequality only 

371 # because we know non-empty Timespans have nonzero duration, and hence 

372 # the second term is never false for non-empty timespans unless the 

373 # first term is also false. 

374 if isinstance(other, astropy.time.Time): 

375 nsec = TimeConverter().astropy_to_nsec(other) 

376 return self._nsec[0] > nsec and self._nsec[1] > nsec 

377 else: 

378 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0] 

379 

380 def overlaps(self, other: Timespan) -> bool: 

381 """Test if the intersection of this Timespan with another is empty. 

382 

383 Parameters 

384 ---------- 

385 other : `Timespan` 

386 Timespan to relate to ``self``. 

387 

388 Returns 

389 ------- 

390 overlaps : `bool` 

391 The result of the overlap test. 

392 

393 Notes 

394 ----- 

395 If either ``self`` or ``other`` is empty, the result is always `False`. 

396 In all other cases, ``self.contains(other)`` being `True` implies that 

397 ``self.overlaps(other)`` is also `True`. 

398 """ 

399 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0] 

400 

401 def contains(self, other: Union[astropy.time.Time, Timespan]) -> bool: 

402 """Test if the supplied timespan is within this one. 

403 

404 Tests whether the intersection of this timespan with another timespan 

405 or point is equal to the other one. 

406 

407 Parameters 

408 ---------- 

409 other : `Timespan` or `astropy.time.Time`. 

410 Timespan or instant in time to relate to ``self``. 

411 

412 Returns 

413 ------- 

414 overlaps : `bool` 

415 The result of the contains test. 

416 

417 Notes 

418 ----- 

419 If ``other`` is empty, `True` is always returned. In all other cases, 

420 ``self.contains(other)`` being `True` implies that 

421 ``self.overlaps(other)`` is also `True`. 

422 

423 Testing whether an instantaneous `astropy.time.Time` value is contained 

424 in a timespan is not equivalent to testing a timespan constructed via 

425 `Timespan.fromInstant`, because Timespan cannot exactly represent 

426 zero-duration intervals. In particular, ``[a, b)`` contains the time 

427 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned 

428 by `Timespan.fromInstant(b)``. 

429 """ 

430 if isinstance(other, astropy.time.Time): 

431 nsec = TimeConverter().astropy_to_nsec(other) 

432 return self._nsec[0] <= nsec and self._nsec[1] > nsec 

433 else: 

434 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1] 

435 

436 def intersection(self, *args: Timespan) -> Timespan: 

437 """Return a new `Timespan` that is contained by all of the given ones. 

438 

439 Parameters 

440 ---------- 

441 *args 

442 All positional arguments are `Timespan` instances. 

443 

444 Returns 

445 ------- 

446 intersection : `Timespan` 

447 The intersection timespan. 

448 """ 

449 if not args: 

450 return self 

451 lowers = [self._nsec[0]] 

452 lowers.extend(ts._nsec[0] for ts in args) 

453 uppers = [self._nsec[1]] 

454 uppers.extend(ts._nsec[1] for ts in args) 

455 nsec = (max(*lowers), min(*uppers)) 

456 return Timespan(begin=None, end=None, _nsec=nsec) 

457 

458 def difference(self, other: Timespan) -> Generator[Timespan, None, None]: 

459 """Return the one or two timespans that cover the interval(s). 

460 

461 The interval is defined as one that is in ``self`` but not ``other``. 

462 

463 This is implemented as a generator because the result may be zero, one, 

464 or two `Timespan` objects, depending on the relationship between the 

465 operands. 

466 

467 Parameters 

468 ---------- 

469 other : `Timespan` 

470 Timespan to subtract. 

471 

472 Yields 

473 ------ 

474 result : `Timespan` 

475 A `Timespan` that is contained by ``self`` but does not overlap 

476 ``other``. Guaranteed not to be empty. 

477 """ 

478 intersection = self.intersection(other) 

479 if intersection.isEmpty(): 

480 yield self 

481 elif intersection == self: 

482 yield from () 

483 else: 

484 if intersection._nsec[0] > self._nsec[0]: 

485 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0])) 

486 if intersection._nsec[1] < self._nsec[1]: 

487 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1])) 

488 

489 def to_simple(self, minimal: bool = False) -> List[int]: 

490 """Return simple python type form suitable for serialization. 

491 

492 Parameters 

493 ---------- 

494 minimal : `bool`, optional 

495 Use minimal serialization. Has no effect on for this class. 

496 

497 Returns 

498 ------- 

499 simple : `list` of `int` 

500 The internal span as integer nanoseconds. 

501 """ 

502 # Return the internal nanosecond form rather than astropy ISO string 

503 return list(self._nsec) 

504 

505 @classmethod 

506 def from_simple(cls, simple: List[int], 

507 universe: Optional[DimensionUniverse] = None, 

508 registry: Optional[Registry] = None) -> Timespan: 

509 """Construct a new object from simplified form. 

510 

511 Designed to use the data returned from the `to_simple` method. 

512 

513 Parameters 

514 ---------- 

515 simple : `list` of `int` 

516 The values returned by `to_simple()`. 

517 universe : `DimensionUniverse`, optional 

518 Unused. 

519 registry : `lsst.daf.butler.Registry`, optional 

520 Unused. 

521 

522 Returns 

523 ------- 

524 result : `Timespan` 

525 Newly-constructed object. 

526 """ 

527 nsec1, nsec2 = simple # for mypy 

528 return cls(begin=None, end=None, _nsec=(nsec1, nsec2)) 

529 

530 to_json = to_json_generic 

531 from_json = classmethod(from_json_generic) 

532 

533 

534_S = TypeVar("_S", bound="TimespanDatabaseRepresentation") 

535 

536 

537class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]): 

538 """Representation of a time span within a database engine. 

539 

540 Provides an interface that encapsulates how timespans are represented in a 

541 database engine. 

542 

543 Most of this class's interface is comprised of classmethods. Instances 

544 can be constructed via the `fromSelectable` or `fromLiteral` methods as a 

545 way to include timespan overlap operations in query JOIN or WHERE clauses. 

546 

547 Notes 

548 ----- 

549 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

550 same interval definitions and edge-case behavior as the `Timespan` class. 

551 They are also guaranteed to round-trip `Timespan` instances exactly. 

552 """ 

553 

554 NAME: ClassVar[str] = "timespan" 

555 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL 

556 

557 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

558 """A concrete subclass of `TimespanDatabaseRepresentation` that simply 

559 uses two separate fields for the begin (inclusive) and end (excusive) 

560 endpoints. 

561 

562 This implementation should be compatible with any SQL database, and should 

563 generally be used when a database-specific implementation is not available. 

564 """ 

565 

566 __slots__ = () 

567 

568 @classmethod 

569 @abstractmethod 

570 def fromLiteral(cls: Type[_S], timespan: Timespan) -> _S: 

571 """Construct a database timespan from a literal `Timespan` instance. 

572 

573 Parameters 

574 ---------- 

575 timespan : `Timespan` 

576 Literal timespan to convert. 

577 

578 Returns 

579 ------- 

580 tsRepr : `TimespanDatabaseRepresentation` 

581 A timespan expression object backed by `sqlalchemy.sql.literal` 

582 column expressions. 

583 """ 

584 raise NotImplementedError() 

585 

586 @abstractmethod 

587 def isEmpty(self) -> sqlalchemy.sql.ColumnElement: 

588 """Return a boolean SQLAlchemy expression for testing empty timespans. 

589 

590 Returns 

591 ------- 

592 empty : `sqlalchemy.sql.ColumnElement` 

593 A boolean SQLAlchemy expression object. 

594 """ 

595 raise NotImplementedError() 

596 

597 @abstractmethod 

598 def __lt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement: 

599 """Return SQLAlchemy expression for testing less than. 

600 

601 Returns a SQLAlchemy expression representing a test for whether an 

602 in-database timespan is strictly less than another timespan or a time 

603 point. 

604 

605 Parameters 

606 ---------- 

607 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement` 

608 The timespan or time to relate to ``self``; either an instance of 

609 the same `TimespanDatabaseRepresentation` subclass as ``self``, or 

610 a SQL column expression representing an `astropy.time.Time`. 

611 

612 Returns 

613 ------- 

614 less : `sqlalchemy.sql.ColumnElement` 

615 A boolean SQLAlchemy expression object. 

616 

617 Notes 

618 ----- 

619 See `Timespan.__lt__` for edge-case behavior. 

620 """ 

621 raise NotImplementedError() 

622 

623 @abstractmethod 

624 def __gt__(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement: 

625 """Return a SQLAlchemy expression for testing greater than. 

626 

627 Returns a SQLAlchemy expression representing a test for whether an 

628 in-database timespan is strictly greater than another timespan or a 

629 time point. 

630 

631 Parameters 

632 ---------- 

633 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement` 

634 The timespan or time to relate to ``self``; either an instance of 

635 the same `TimespanDatabaseRepresentation` subclass as ``self``, or 

636 a SQL column expression representing an `astropy.time.Time`. 

637 

638 Returns 

639 ------- 

640 greater : `sqlalchemy.sql.ColumnElement` 

641 A boolean SQLAlchemy expression object. 

642 

643 Notes 

644 ----- 

645 See `Timespan.__gt__` for edge-case behavior. 

646 """ 

647 raise NotImplementedError() 

648 

649 @abstractmethod 

650 def overlaps(self: _S, other: _S) -> sqlalchemy.sql.ColumnElement: 

651 """Return a SQLAlchemy expression representing timespan overlaps. 

652 

653 Parameters 

654 ---------- 

655 other : ``type(self)`` 

656 The timespan to overlap ``self`` with. 

657 

658 Returns 

659 ------- 

660 overlap : `sqlalchemy.sql.ColumnElement` 

661 A boolean SQLAlchemy expression object. 

662 

663 Notes 

664 ----- 

665 See `Timespan.overlaps` for edge-case behavior. 

666 """ 

667 raise NotImplementedError() 

668 

669 @abstractmethod 

670 def contains(self: _S, other: Union[_S, sqlalchemy.sql.ColumnElement]) -> sqlalchemy.sql.ColumnElement: 

671 """Return a SQLAlchemy expression representing containment. 

672 

673 Returns a test for whether an in-database timespan contains another 

674 timespan or a time point. 

675 

676 Parameters 

677 ---------- 

678 other : ``type(self)`` or `sqlalchemy.sql.ColumnElement` 

679 The timespan or time to relate to ``self``; either an instance of 

680 the same `TimespanDatabaseRepresentation` subclass as ``self``, or 

681 a SQL column expression representing an `astropy.time.Time`. 

682 

683 Returns 

684 ------- 

685 contains : `sqlalchemy.sql.ColumnElement` 

686 A boolean SQLAlchemy expression object. 

687 

688 Notes 

689 ----- 

690 See `Timespan.contains` for edge-case behavior. 

691 """ 

692 raise NotImplementedError() 

693 

694 

695class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

696 """Representation of a time span as two separate fields. 

697 

698 An implementation of `TimespanDatabaseRepresentation` that simply stores 

699 the endpoints in two separate fields. 

700 

701 This type should generally be accessed via 

702 `TimespanDatabaseRepresentation.Compound`, and should be constructed only 

703 via the `fromSelectable` and `fromLiteral` methods. 

704 

705 Parameters 

706 ---------- 

707 nsec : `tuple` of `sqlalchemy.sql.ColumnElement` 

708 Tuple of SQLAlchemy objects representing the lower (inclusive) and 

709 upper (exclusive) bounds, as 64-bit integer columns containing 

710 nanoseconds. 

711 name : `str`, optional 

712 Name for the logical column; a part of the name for multi-column 

713 representations. Defaults to ``cls.NAME``. 

714 

715 Notes 

716 ----- 

717 ``NULL`` timespans are represented by having both fields set to ``NULL``; 

718 setting only one to ``NULL`` is considered a corrupted state that should 

719 only be possible if this interface is circumvented. `Timespan` instances 

720 with one or both of `~Timespan.begin` and `~Timespan.end` set to `None` 

721 are set to fields mapped to the minimum and maximum value constants used 

722 by our integer-time mapping. 

723 """ 

724 

725 def __init__(self, nsec: Tuple[sqlalchemy.sql.ColumnElement, sqlalchemy.sql.ColumnElement], name: str): 

726 self._nsec = nsec 

727 self._name = name 

728 

729 __slots__ = ("_nsec", "_name") 

730 

731 @classmethod 

732 def makeFieldSpecs(cls, nullable: bool, name: Optional[str] = None, **kwargs: Any 

733 ) -> Tuple[ddl.FieldSpec, ...]: 

734 # Docstring inherited. 

735 if name is None: 

736 name = cls.NAME 

737 return ( 

738 ddl.FieldSpec( 

739 f"{name}_begin", dtype=sqlalchemy.BigInteger, nullable=nullable, 

740 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().min_nsec))), 

741 **kwargs, 

742 ), 

743 ddl.FieldSpec( 

744 f"{name}_end", dtype=sqlalchemy.BigInteger, nullable=nullable, 

745 default=(None if nullable else sqlalchemy.sql.text(str(TimeConverter().max_nsec))), 

746 **kwargs, 

747 ), 

748 ) 

749 

750 @classmethod 

751 def getFieldNames(cls, name: Optional[str] = None) -> Tuple[str, ...]: 

752 # Docstring inherited. 

753 if name is None: 

754 name = cls.NAME 

755 return (f"{name}_begin", f"{name}_end") 

756 

757 @classmethod 

758 def update(cls, extent: Optional[Timespan], name: Optional[str] = None, 

759 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 

760 # Docstring inherited. 

761 if name is None: 

762 name = cls.NAME 

763 if result is None: 

764 result = {} 

765 if extent is None: 

766 begin_nsec = None 

767 end_nsec = None 

768 else: 

769 begin_nsec = extent._nsec[0] 

770 end_nsec = extent._nsec[1] 

771 result[f"{name}_begin"] = begin_nsec 

772 result[f"{name}_end"] = end_nsec 

773 return result 

774 

775 @classmethod 

776 def extract(cls, mapping: Mapping[str, Any], name: Optional[str] = None) -> Optional[Timespan]: 

777 # Docstring inherited. 

778 if name is None: 

779 name = cls.NAME 

780 begin_nsec = mapping[f"{name}_begin"] 

781 end_nsec = mapping[f"{name}_end"] 

782 if begin_nsec is None: 

783 if end_nsec is not None: 

784 raise RuntimeError( 

785 f"Corrupted timespan extracted: begin is NULL, but end is {end_nsec}ns -> " 

786 f"{TimeConverter().nsec_to_astropy(end_nsec).tai.isot}." 

787 ) 

788 return None 

789 elif end_nsec is None: 

790 raise RuntimeError( 

791 f"Corrupted timespan extracted: end is NULL, but begin is {begin_nsec}ns -> " 

792 f"{TimeConverter().nsec_to_astropy(begin_nsec).tai.isot}." 

793 ) 

794 return Timespan(None, None, _nsec=(begin_nsec, end_nsec)) 

795 

796 @classmethod 

797 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause, 

798 name: Optional[str] = None) -> _CompoundTimespanDatabaseRepresentation: 

799 # Docstring inherited. 

800 if name is None: 

801 name = cls.NAME 

802 return cls(nsec=(selectable.columns[f"{name}_begin"], selectable.columns[f"{name}_end"]), 

803 name=name) 

804 

805 @classmethod 

806 def fromLiteral(cls, timespan: Timespan) -> _CompoundTimespanDatabaseRepresentation: 

807 # Docstring inherited. 

808 return cls( 

809 nsec=(sqlalchemy.sql.literal(timespan._nsec[0]), sqlalchemy.sql.literal(timespan._nsec[1])), 

810 name=cls.NAME, 

811 ) 

812 

813 @property 

814 def name(self) -> str: 

815 # Docstring inherited. 

816 return self._name 

817 

818 def isNull(self) -> sqlalchemy.sql.ColumnElement: 

819 # Docstring inherited. 

820 return self._nsec[0].is_(None) 

821 

822 def isEmpty(self) -> sqlalchemy.sql.ColumnElement: 

823 # Docstring inherited. 

824 return self._nsec[0] >= self._nsec[1] 

825 

826 def __lt__( 

827 self, 

828 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

829 ) -> sqlalchemy.sql.ColumnElement: 

830 # Docstring inherited. 

831 # See comments in Timespan.__lt__ for why we use these exact 

832 # expressions. 

833 if isinstance(other, sqlalchemy.sql.ColumnElement): 

834 return sqlalchemy.sql.and_(self._nsec[1] <= other, self._nsec[0] < other) 

835 else: 

836 return sqlalchemy.sql.and_(self._nsec[1] <= other._nsec[0], self._nsec[0] < other._nsec[1]) 

837 

838 def __gt__( 

839 self, 

840 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

841 ) -> sqlalchemy.sql.ColumnElement: 

842 # Docstring inherited. 

843 # See comments in Timespan.__gt__ for why we use these exact 

844 # expressions. 

845 if isinstance(other, sqlalchemy.sql.ColumnElement): 

846 return sqlalchemy.sql.and_(self._nsec[0] > other, self._nsec[1] > other) 

847 else: 

848 return sqlalchemy.sql.and_(self._nsec[0] >= other._nsec[1], self._nsec[1] > other._nsec[0]) 

849 

850 def overlaps(self, other: _CompoundTimespanDatabaseRepresentation) -> sqlalchemy.sql.ColumnElement: 

851 # Docstring inherited. 

852 return sqlalchemy.sql.and_(self._nsec[1] > other._nsec[0], other._nsec[1] > self._nsec[0]) 

853 

854 def contains( 

855 self, 

856 other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

857 ) -> sqlalchemy.sql.ColumnElement: 

858 # Docstring inherited. 

859 if isinstance(other, sqlalchemy.sql.ColumnElement): 

860 return sqlalchemy.sql.and_(self._nsec[0] <= other, self._nsec[1] > other) 

861 else: 

862 return sqlalchemy.sql.and_(self._nsec[0] <= other._nsec[0], self._nsec[1] >= other._nsec[1]) 

863 

864 def flatten(self, name: Optional[str] = None) -> Iterator[sqlalchemy.sql.ColumnElement]: 

865 # Docstring inherited. 

866 if name is None: 

867 yield from self._nsec 

868 else: 

869 yield self._nsec[0].label(f"{name}_begin") 

870 yield self._nsec[1].label(f"{name}_end") 

871 

872 

873TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation