Hide keyboard shortcuts

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 

22 

23__all__ = ( 

24 "Timespan", 

25 "TimespanDatabaseRepresentation", 

26) 

27 

28from abc import abstractmethod 

29import enum 

30from typing import ( 

31 Any, 

32 ClassVar, 

33 Dict, 

34 Generator, 

35 Iterator, 

36 Mapping, 

37 Optional, 

38 Tuple, 

39 Type, 

40 TypeVar, 

41 Union, 

42) 

43 

44import astropy.time 

45import astropy.utils.exceptions 

46import sqlalchemy 

47import warnings 

48 

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

50# ErfaWarning is no longer an AstropyWarning 

51try: 

52 import erfa 

53except ImportError: 

54 erfa = None 

55 

56from . import ddl 

57from .time_utils import TimeConverter 

58from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace 

59from .utils import cached_getter 

60 

61 

62class _SpecialTimespanBound(enum.Enum): 

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

64 

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

66 `Timespan.EMPTY` alias. 

67 """ 

68 

69 EMPTY = enum.auto() 

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

71 Timespans that contain no points. 

72 """ 

73 

74 

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

76 

77 

78class Timespan: 

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

80 

81 Parameters 

82 ---------- 

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

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

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

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

87 bound is ignored. 

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

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

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

91 creates an empty timespan. 

92 padInstantaneous : `bool`, optional 

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

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

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

96 the empty timespan. 

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

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

99 `TimespanDatabaseRepresentation` implementation only. If provided, 

100 all other arguments are are ignored. 

101 

102 Raises 

103 ------ 

104 TypeError 

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

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

107 ValueError 

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

109 supported by this class. 

110 

111 Notes 

112 ----- 

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

114 

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

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

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

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

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

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

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

122 ``overlaps``. 

123 

124 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

132 preserved even if the values are not. 

133 

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

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

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

137 """ 

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

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

140 converter = TimeConverter() 

141 if _nsec is None: 

142 begin_nsec: int 

143 if begin is None: 

144 begin_nsec = converter.min_nsec 

145 elif begin is self.EMPTY: 

146 begin_nsec = converter.max_nsec 

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

148 begin_nsec = converter.astropy_to_nsec(begin) 

149 else: 

150 raise TypeError( 

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

152 ) 

153 end_nsec: int 

154 if end is None: 

155 end_nsec = converter.max_nsec 

156 elif end is self.EMPTY: 

157 end_nsec = converter.min_nsec 

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

159 end_nsec = converter.astropy_to_nsec(end) 

160 else: 

161 raise TypeError( 

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

163 ) 

164 if begin_nsec == end_nsec: 

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

166 with warnings.catch_warnings(): 

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

168 if erfa is not None: 

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

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

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

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

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

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

175 elif padInstantaneous: 

176 end_nsec += 1 

177 if end_nsec == converter.max_nsec: 

178 raise ValueError( 

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

180 "within one ns of maximum time." 

181 ) 

182 _nsec = (begin_nsec, end_nsec) 

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

184 # Standardizing all empty timespans to the same underlying values 

185 # here simplifies all other operations (including interactions 

186 # with TimespanDatabaseRepresentation implementations). 

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

188 self._nsec = _nsec 

189 

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

191 

192 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

193 

194 @classmethod 

195 def makeEmpty(cls) -> Timespan: 

196 """Construct an empty timespan. 

197 

198 Returns 

199 ------- 

200 empty : `Timespan` 

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

202 and overlaps no other timespans (including itself). 

203 """ 

204 converter = TimeConverter() 

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

206 

207 @classmethod 

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

209 """Construct a timespan that approximates an instant in time by a 

210 minimum-possible (1 ns) duration timespan. 

211 

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

213 but may be slightly more efficient. 

214 

215 Parameters 

216 ---------- 

217 time : `astropy.time.Time` 

218 Time to use for the lower bound. 

219 

220 Returns 

221 ------- 

222 instant : `Timespan` 

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

224 """ 

225 converter = TimeConverter() 

226 nsec = converter.astropy_to_nsec(time) 

227 if nsec == converter.max_nsec - 1: 

228 raise ValueError( 

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

230 "within one ns of maximum time." 

231 ) 

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

233 

234 @property # type: ignore 

235 @cached_getter 

236 def begin(self) -> TimespanBound: 

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

238 

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

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

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

242 """ 

243 if self.isEmpty(): 

244 return self.EMPTY 

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

246 return None 

247 else: 

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

249 

250 @property # type: ignore 

251 @cached_getter 

252 def end(self) -> TimespanBound: 

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

254 

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

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

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

258 """ 

259 if self.isEmpty(): 

260 return self.EMPTY 

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

262 return None 

263 else: 

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

265 

266 def isEmpty(self) -> bool: 

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

268 """ 

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

270 

271 def __str__(self) -> str: 

272 if self.isEmpty(): 

273 return "(empty)" 

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

275 # simulated data in the future 

276 with warnings.catch_warnings(): 

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

278 if erfa is not None: 

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

280 if self.begin is None: 

281 head = "(-∞, " 

282 else: 

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

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

285 if self.end is None: 

286 tail = "∞)" 

287 else: 

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

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

290 return head + tail 

291 

292 def __repr__(self) -> str: 

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

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

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

296 # eval-friendly __repr__. 

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

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

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

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

301 

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

303 if not isinstance(other, Timespan): 

304 return False 

305 # Correctness of this simple implementation depends on __init__ 

306 # standardizing all empty timespans to a single value. 

307 return self._nsec == other._nsec 

308 

309 def __hash__(self) -> int: 

310 # Correctness of this simple implementation depends on __init__ 

311 # standardizing all empty timespans to a single value. 

312 return hash(self._nsec) 

313 

314 def __reduce__(self) -> tuple: 

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

316 

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

318 """Test whether a Timespan's bounds are strictly less than the given 

319 time or timespan. 

320 

321 

322 Parameters 

323 ---------- 

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

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

326 

327 Returns 

328 ------- 

329 less : `bool` 

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

331 empty. 

332 """ 

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

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

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

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

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

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

339 # first term is also false. 

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

341 nsec = TimeConverter().astropy_to_nsec(other) 

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

343 else: 

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

345 

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

347 """Test whether a Timespan's bounds are strictly greater than the given 

348 time or timespan. 

349 

350 

351 Parameters 

352 ---------- 

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

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

355 

356 Returns 

357 ------- 

358 greater : `bool` 

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

360 empty. 

361 """ 

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

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

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

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

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

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

368 # first term is also false. 

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

370 nsec = TimeConverter().astropy_to_nsec(other) 

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

372 else: 

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

374 

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

376 """Test whether the intersection of this Timespan with another 

377 is empty. 

378 

379 Parameters 

380 ---------- 

381 other : `Timespan` 

382 Timespan to relate to ``self``. 

383 

384 Returns 

385 ------- 

386 overlaps : `bool` 

387 The result of the overlap test. 

388 

389 Notes 

390 ----- 

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

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

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

394 """ 

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

396 

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

398 """Test whether the intersection of this timespan with another timespan 

399 or point is equal to the other one. 

400 

401 Parameters 

402 ---------- 

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

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

405 

406 Returns 

407 ------- 

408 overlaps : `bool` 

409 The result of the contains test. 

410 

411 Notes 

412 ----- 

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

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

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

416 

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

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

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

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

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

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

423 """ 

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

425 nsec = TimeConverter().astropy_to_nsec(other) 

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

427 else: 

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

429 

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

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

432 

433 Parameters 

434 ---------- 

435 *args 

436 All positional arguments are `Timespan` instances. 

437 

438 Returns 

439 ------- 

440 intersection : `Timespan` 

441 The intersection timespan. 

442 """ 

443 if not args: 

444 return self 

445 lowers = [self._nsec[0]] 

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

447 uppers = [self._nsec[1]] 

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

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

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

451 

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

453 """Return the one or two timespans that cover the interval(s) that are 

454 in ``self`` but not ``other``. 

455 

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

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

458 operands. 

459 

460 Parameters 

461 ---------- 

462 other : `Timespan` 

463 Timespan to subtract. 

464 

465 Yields 

466 ------ 

467 result : `Timespan` 

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

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

470 """ 

471 intersection = self.intersection(other) 

472 if intersection.isEmpty(): 

473 yield self 

474 elif intersection == self: 

475 yield from () 

476 else: 

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

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

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

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

481 

482 

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

484 

485 

486class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]): 

487 """An interface that encapsulates how timespans are represented in a 

488 database engine. 

489 

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

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

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

493 

494 Notes 

495 ----- 

496 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

499 """ 

500 

501 NAME: ClassVar[str] = "timespan" 

502 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL 

503 

504 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

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

507 endpoints. 

508 

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

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

511 """ 

512 

513 __slots__ = () 

514 

515 @classmethod 

516 @abstractmethod 

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

518 """Construct a database timespan representation from a literal 

519 `Timespan` instance. 

520 

521 Parameters 

522 ---------- 

523 timespan : `Timespan` 

524 Literal timespan to convert. 

525 

526 Returns 

527 ------- 

528 tsRepr : `TimespanDatabaseRepresentation` 

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

530 column expressions. 

531 """ 

532 raise NotImplementedError() 

533 

534 @abstractmethod 

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

536 """Return a boolean SQLAlchemy expression that tests whether the 

537 timespan is empty. 

538 

539 Returns 

540 ------- 

541 empty : `sqlalchemy.sql.ColumnElement` 

542 A boolean SQLAlchemy expression object. 

543 """ 

544 raise NotImplementedError() 

545 

546 @abstractmethod 

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

548 """Return a SQLAlchemy expression representing a test for whether an 

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

550 point. 

551 

552 Parameters 

553 ---------- 

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

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

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

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

558 

559 Returns 

560 ------- 

561 less : `sqlalchemy.sql.ColumnElement` 

562 A boolean SQLAlchemy expression object. 

563 

564 Notes 

565 ----- 

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

567 """ 

568 raise NotImplementedError() 

569 

570 @abstractmethod 

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

572 """Return a SQLAlchemy expression representing a test for whether an 

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

574 time point. 

575 

576 Parameters 

577 ---------- 

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

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

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

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

582 

583 Returns 

584 ------- 

585 greater : `sqlalchemy.sql.ColumnElement` 

586 A boolean SQLAlchemy expression object. 

587 

588 Notes 

589 ----- 

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

591 """ 

592 raise NotImplementedError() 

593 

594 @abstractmethod 

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

596 """Return a SQLAlchemy expression representing an overlap operation on 

597 timespans. 

598 

599 Parameters 

600 ---------- 

601 other : ``type(self)`` 

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

603 

604 Returns 

605 ------- 

606 overlap : `sqlalchemy.sql.ColumnElement` 

607 A boolean SQLAlchemy expression object. 

608 

609 Notes 

610 ----- 

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

612 """ 

613 raise NotImplementedError() 

614 

615 @abstractmethod 

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

617 """Return a SQLAlchemy expression representing a test for whether an 

618 in-database timespan contains another timespan or a time point. 

619 

620 Parameters 

621 ---------- 

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

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

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

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

626 

627 Returns 

628 ------- 

629 contains : `sqlalchemy.sql.ColumnElement` 

630 A boolean SQLAlchemy expression object. 

631 

632 Notes 

633 ----- 

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

635 """ 

636 raise NotImplementedError() 

637 

638 

639class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

640 """An implementation of `TimespanDatabaseRepresentation` that simply stores 

641 the endpoints in two separate fields. 

642 

643 This type should generally be accessed via 

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

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

646 

647 Parameters 

648 ---------- 

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

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

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

652 nanoseconds. 

653 name : `str`, optional 

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

655 representations. Defaults to ``cls.NAME``. 

656 

657 Notes 

658 ----- 

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

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

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

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

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

664 by our integer-time mapping. 

665 """ 

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

667 self._nsec = nsec 

668 self._name = name 

669 

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

671 

672 @classmethod 

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

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

675 # Docstring inherited. 

676 if name is None: 

677 name = cls.NAME 

678 return ( 

679 ddl.FieldSpec( 

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

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

682 **kwargs, 

683 ), 

684 ddl.FieldSpec( 

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

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

687 **kwargs, 

688 ), 

689 ) 

690 

691 @classmethod 

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

693 # Docstring inherited. 

694 if name is None: 

695 name = cls.NAME 

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

697 

698 @classmethod 

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

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

701 # Docstring inherited. 

702 if name is None: 

703 name = cls.NAME 

704 if result is None: 

705 result = {} 

706 if extent is None: 

707 begin_nsec = None 

708 end_nsec = None 

709 else: 

710 begin_nsec = extent._nsec[0] 

711 end_nsec = extent._nsec[1] 

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

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

714 return result 

715 

716 @classmethod 

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

718 # Docstring inherited. 

719 if name is None: 

720 name = cls.NAME 

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

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

723 if begin_nsec is None: 

724 if end_nsec is not None: 

725 raise RuntimeError( 

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

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

728 ) 

729 return None 

730 elif end_nsec is None: 

731 raise RuntimeError( 

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

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

734 ) 

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

736 

737 @classmethod 

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

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

740 # Docstring inherited. 

741 if name is None: 

742 name = cls.NAME 

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

744 name=name) 

745 

746 @classmethod 

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

748 # Docstring inherited. 

749 return cls( 

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

751 name=cls.NAME, 

752 ) 

753 

754 @property 

755 def name(self) -> str: 

756 # Docstring inherited. 

757 return self._name 

758 

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

760 # Docstring inherited. 

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

762 

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

764 # Docstring inherited. 

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

766 

767 def __lt__( 

768 self, 

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

770 ) -> sqlalchemy.sql.ColumnElement: 

771 # Docstring inherited. 

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

773 # expressions. 

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

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

776 else: 

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

778 

779 def __gt__( 

780 self, 

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

782 ) -> sqlalchemy.sql.ColumnElement: 

783 # Docstring inherited. 

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

785 # expressions. 

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

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

788 else: 

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

790 

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

792 # Docstring inherited. 

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

794 

795 def contains( 

796 self, 

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

798 ) -> sqlalchemy.sql.ColumnElement: 

799 # Docstring inherited. 

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

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

802 else: 

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

804 

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

806 # Docstring inherited. 

807 if name is None: 

808 yield from self._nsec 

809 else: 

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

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

812 

813 

814TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation