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 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 def __init__(self, begin: TimespanBound, end: TimespanBound, padInstantaneous: bool = True, 

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

147 converter = TimeConverter() 

148 if _nsec is None: 

149 begin_nsec: int 

150 if begin is None: 

151 begin_nsec = converter.min_nsec 

152 elif begin is self.EMPTY: 

153 begin_nsec = converter.max_nsec 

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

155 begin_nsec = converter.astropy_to_nsec(begin) 

156 else: 

157 raise TypeError( 

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

159 ) 

160 end_nsec: int 

161 if end is None: 

162 end_nsec = converter.max_nsec 

163 elif end is self.EMPTY: 

164 end_nsec = converter.min_nsec 

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

166 end_nsec = converter.astropy_to_nsec(end) 

167 else: 

168 raise TypeError( 

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

170 ) 

171 if begin_nsec == end_nsec: 

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

173 with warnings.catch_warnings(): 

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

175 if erfa is not None: 

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

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

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

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

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

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

182 elif padInstantaneous: 

183 end_nsec += 1 

184 if end_nsec == converter.max_nsec: 

185 raise ValueError( 

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

187 "within one ns of maximum time." 

188 ) 

189 _nsec = (begin_nsec, end_nsec) 

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

191 # Standardizing all empty timespans to the same underlying values 

192 # here simplifies all other operations (including interactions 

193 # with TimespanDatabaseRepresentation implementations). 

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

195 self._nsec = _nsec 

196 

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

198 

199 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

200 

201 @classmethod 

202 def makeEmpty(cls) -> Timespan: 

203 """Construct an empty timespan. 

204 

205 Returns 

206 ------- 

207 empty : `Timespan` 

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

209 and overlaps no other timespans (including itself). 

210 """ 

211 converter = TimeConverter() 

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

213 

214 @classmethod 

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

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

217 minimum-possible (1 ns) duration timespan. 

218 

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

220 but may be slightly more efficient. 

221 

222 Parameters 

223 ---------- 

224 time : `astropy.time.Time` 

225 Time to use for the lower bound. 

226 

227 Returns 

228 ------- 

229 instant : `Timespan` 

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

231 """ 

232 converter = TimeConverter() 

233 nsec = converter.astropy_to_nsec(time) 

234 if nsec == converter.max_nsec - 1: 

235 raise ValueError( 

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

237 "within one ns of maximum time." 

238 ) 

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

240 

241 @property # type: ignore 

242 @cached_getter 

243 def begin(self) -> TimespanBound: 

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

245 

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

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

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

249 """ 

250 if self.isEmpty(): 

251 return self.EMPTY 

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

253 return None 

254 else: 

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

256 

257 @property # type: ignore 

258 @cached_getter 

259 def end(self) -> TimespanBound: 

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

261 

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

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

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

265 """ 

266 if self.isEmpty(): 

267 return self.EMPTY 

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

269 return None 

270 else: 

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

272 

273 def isEmpty(self) -> bool: 

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

275 """ 

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

277 

278 def __str__(self) -> str: 

279 if self.isEmpty(): 

280 return "(empty)" 

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

282 # simulated data in the future 

283 with warnings.catch_warnings(): 

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

285 if erfa is not None: 

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

287 if self.begin is None: 

288 head = "(-∞, " 

289 else: 

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

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

292 if self.end is None: 

293 tail = "∞)" 

294 else: 

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

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

297 return head + tail 

298 

299 def __repr__(self) -> str: 

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

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

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

303 # eval-friendly __repr__. 

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

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

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

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

308 

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

310 if not isinstance(other, Timespan): 

311 return False 

312 # Correctness of this simple implementation depends on __init__ 

313 # standardizing all empty timespans to a single value. 

314 return self._nsec == other._nsec 

315 

316 def __hash__(self) -> int: 

317 # Correctness of this simple implementation depends on __init__ 

318 # standardizing all empty timespans to a single value. 

319 return hash(self._nsec) 

320 

321 def __reduce__(self) -> tuple: 

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

323 

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

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

326 time or timespan. 

327 

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 whether a Timespan's bounds are strictly greater than the given 

355 time or timespan. 

356 

357 

358 Parameters 

359 ---------- 

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

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

362 

363 Returns 

364 ------- 

365 greater : `bool` 

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

367 empty. 

368 """ 

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

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

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

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

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

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

375 # first term is also false. 

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

377 nsec = TimeConverter().astropy_to_nsec(other) 

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

379 else: 

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

381 

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

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

384 is empty. 

385 

386 Parameters 

387 ---------- 

388 other : `Timespan` 

389 Timespan to relate to ``self``. 

390 

391 Returns 

392 ------- 

393 overlaps : `bool` 

394 The result of the overlap test. 

395 

396 Notes 

397 ----- 

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

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

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

401 """ 

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

403 

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

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

406 or point is equal to the other one. 

407 

408 Parameters 

409 ---------- 

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

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

412 

413 Returns 

414 ------- 

415 overlaps : `bool` 

416 The result of the contains test. 

417 

418 Notes 

419 ----- 

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

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

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

423 

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

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

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

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

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

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

430 """ 

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

432 nsec = TimeConverter().astropy_to_nsec(other) 

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

434 else: 

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

436 

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

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

439 

440 Parameters 

441 ---------- 

442 *args 

443 All positional arguments are `Timespan` instances. 

444 

445 Returns 

446 ------- 

447 intersection : `Timespan` 

448 The intersection timespan. 

449 """ 

450 if not args: 

451 return self 

452 lowers = [self._nsec[0]] 

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

454 uppers = [self._nsec[1]] 

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

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

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

458 

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

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

461 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 """Convert this class to a simple python type suitable for 

491 serialization. 

492 

493 Parameters 

494 ---------- 

495 minimal : `bool`, optional 

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

497 

498 Returns 

499 ------- 

500 simple : `list` of `int` 

501 The internal span as integer nanoseconds. 

502 """ 

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

504 return list(self._nsec) 

505 

506 @classmethod 

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

508 universe: Optional[DimensionUniverse] = None, 

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

510 """Construct a new object from the data returned from the `to_simple` 

511 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 """An interface that encapsulates how timespans are represented in a 

539 database engine. 

540 

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

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

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

544 

545 Notes 

546 ----- 

547 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

550 """ 

551 

552 NAME: ClassVar[str] = "timespan" 

553 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL 

554 

555 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

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

558 endpoints. 

559 

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

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

562 """ 

563 

564 __slots__ = () 

565 

566 @classmethod 

567 @abstractmethod 

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

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

570 `Timespan` instance. 

571 

572 Parameters 

573 ---------- 

574 timespan : `Timespan` 

575 Literal timespan to convert. 

576 

577 Returns 

578 ------- 

579 tsRepr : `TimespanDatabaseRepresentation` 

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

581 column expressions. 

582 """ 

583 raise NotImplementedError() 

584 

585 @abstractmethod 

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

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

588 timespan is empty. 

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 a SQLAlchemy expression representing a test for whether an 

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

601 point. 

602 

603 Parameters 

604 ---------- 

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

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

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

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

609 

610 Returns 

611 ------- 

612 less : `sqlalchemy.sql.ColumnElement` 

613 A boolean SQLAlchemy expression object. 

614 

615 Notes 

616 ----- 

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

618 """ 

619 raise NotImplementedError() 

620 

621 @abstractmethod 

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

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

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

625 time point. 

626 

627 Parameters 

628 ---------- 

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

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

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

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

633 

634 Returns 

635 ------- 

636 greater : `sqlalchemy.sql.ColumnElement` 

637 A boolean SQLAlchemy expression object. 

638 

639 Notes 

640 ----- 

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

642 """ 

643 raise NotImplementedError() 

644 

645 @abstractmethod 

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

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

648 timespans. 

649 

650 Parameters 

651 ---------- 

652 other : ``type(self)`` 

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

654 

655 Returns 

656 ------- 

657 overlap : `sqlalchemy.sql.ColumnElement` 

658 A boolean SQLAlchemy expression object. 

659 

660 Notes 

661 ----- 

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

663 """ 

664 raise NotImplementedError() 

665 

666 @abstractmethod 

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

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

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

670 

671 Parameters 

672 ---------- 

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

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

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

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

677 

678 Returns 

679 ------- 

680 contains : `sqlalchemy.sql.ColumnElement` 

681 A boolean SQLAlchemy expression object. 

682 

683 Notes 

684 ----- 

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

686 """ 

687 raise NotImplementedError() 

688 

689 

690class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

692 the endpoints in two separate fields. 

693 

694 This type should generally be accessed via 

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

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

697 

698 Parameters 

699 ---------- 

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

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

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

703 nanoseconds. 

704 name : `str`, optional 

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

706 representations. Defaults to ``cls.NAME``. 

707 

708 Notes 

709 ----- 

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

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

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

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

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

715 by our integer-time mapping. 

716 """ 

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

718 self._nsec = nsec 

719 self._name = name 

720 

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

722 

723 @classmethod 

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

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

726 # Docstring inherited. 

727 if name is None: 

728 name = cls.NAME 

729 return ( 

730 ddl.FieldSpec( 

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

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

733 **kwargs, 

734 ), 

735 ddl.FieldSpec( 

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

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

738 **kwargs, 

739 ), 

740 ) 

741 

742 @classmethod 

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

744 # Docstring inherited. 

745 if name is None: 

746 name = cls.NAME 

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

748 

749 @classmethod 

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

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

752 # Docstring inherited. 

753 if name is None: 

754 name = cls.NAME 

755 if result is None: 

756 result = {} 

757 if extent is None: 

758 begin_nsec = None 

759 end_nsec = None 

760 else: 

761 begin_nsec = extent._nsec[0] 

762 end_nsec = extent._nsec[1] 

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

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

765 return result 

766 

767 @classmethod 

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

769 # Docstring inherited. 

770 if name is None: 

771 name = cls.NAME 

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

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

774 if begin_nsec is None: 

775 if end_nsec is not None: 

776 raise RuntimeError( 

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

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

779 ) 

780 return None 

781 elif end_nsec is None: 

782 raise RuntimeError( 

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

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

785 ) 

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

787 

788 @classmethod 

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

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

791 # Docstring inherited. 

792 if name is None: 

793 name = cls.NAME 

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

795 name=name) 

796 

797 @classmethod 

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

799 # Docstring inherited. 

800 return cls( 

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

802 name=cls.NAME, 

803 ) 

804 

805 @property 

806 def name(self) -> str: 

807 # Docstring inherited. 

808 return self._name 

809 

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

811 # Docstring inherited. 

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

813 

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

815 # Docstring inherited. 

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

817 

818 def __lt__( 

819 self, 

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

821 ) -> sqlalchemy.sql.ColumnElement: 

822 # Docstring inherited. 

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

824 # expressions. 

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

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

827 else: 

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

829 

830 def __gt__( 

831 self, 

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

833 ) -> sqlalchemy.sql.ColumnElement: 

834 # Docstring inherited. 

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

836 # expressions. 

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

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

839 else: 

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

841 

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

843 # Docstring inherited. 

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

845 

846 def contains( 

847 self, 

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

849 ) -> sqlalchemy.sql.ColumnElement: 

850 # Docstring inherited. 

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

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

853 else: 

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

855 

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

857 # Docstring inherited. 

858 if name is None: 

859 yield from self._nsec 

860 else: 

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

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

863 

864 

865TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation