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

295 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 

50import yaml 

51 

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

53# ErfaWarning is no longer an AstropyWarning 

54try: 

55 import erfa 

56except ImportError: 

57 erfa = None 

58 

59from lsst.utils.classes import cached_getter 

60from . import ddl 

61from .time_utils import TimeConverter 

62from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace 

63from .json import from_json_generic, to_json_generic 

64 

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

66 from .dimensions import DimensionUniverse 

67 from ..registry import Registry 

68 

69 

70class _SpecialTimespanBound(enum.Enum): 

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

72 

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

74 `Timespan.EMPTY` alias. 

75 """ 

76 

77 EMPTY = enum.auto() 

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

79 Timespans that contain no points. 

80 """ 

81 

82 

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

84 

85 

86class Timespan: 

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

88 

89 Parameters 

90 ---------- 

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

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

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

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

95 bound is ignored. 

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

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

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

99 creates an empty timespan. 

100 padInstantaneous : `bool`, optional 

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

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

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

104 the empty timespan. 

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

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

107 `TimespanDatabaseRepresentation` implementation only. If provided, 

108 all other arguments are are ignored. 

109 

110 Raises 

111 ------ 

112 TypeError 

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

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

115 ValueError 

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

117 supported by this class. 

118 

119 Notes 

120 ----- 

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

122 

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

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

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

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

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

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

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

130 ``overlaps``. 

131 

132 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

140 preserved even if the values are not. 

141 

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

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

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

145 """ 

146 

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

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

149 converter = TimeConverter() 

150 if _nsec is None: 

151 begin_nsec: int 

152 if begin is None: 

153 begin_nsec = converter.min_nsec 

154 elif begin is self.EMPTY: 

155 begin_nsec = converter.max_nsec 

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

157 begin_nsec = converter.astropy_to_nsec(begin) 

158 else: 

159 raise TypeError( 

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

161 ) 

162 end_nsec: int 

163 if end is None: 

164 end_nsec = converter.max_nsec 

165 elif end is self.EMPTY: 

166 end_nsec = converter.min_nsec 

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

168 end_nsec = converter.astropy_to_nsec(end) 

169 else: 

170 raise TypeError( 

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

172 ) 

173 if begin_nsec == end_nsec: 

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

175 with warnings.catch_warnings(): 

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

177 if erfa is not None: 

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

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

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

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

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

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

184 elif padInstantaneous: 

185 end_nsec += 1 

186 if end_nsec == converter.max_nsec: 

187 raise ValueError( 

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

189 "within one ns of maximum time." 

190 ) 

191 _nsec = (begin_nsec, end_nsec) 

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

193 # Standardizing all empty timespans to the same underlying values 

194 # here simplifies all other operations (including interactions 

195 # with TimespanDatabaseRepresentation implementations). 

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

197 self._nsec = _nsec 

198 

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

200 

201 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

202 

203 # YAML tag name for Timespan 

204 yaml_tag = "!lsst.daf.butler.Timespan" 

205 

206 @classmethod 

207 def makeEmpty(cls) -> Timespan: 

208 """Construct an empty timespan. 

209 

210 Returns 

211 ------- 

212 empty : `Timespan` 

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

214 and overlaps no other timespans (including itself). 

215 """ 

216 converter = TimeConverter() 

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

218 

219 @classmethod 

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

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

222 

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

224 timespan. 

225 

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

227 but may be slightly more efficient. 

228 

229 Parameters 

230 ---------- 

231 time : `astropy.time.Time` 

232 Time to use for the lower bound. 

233 

234 Returns 

235 ------- 

236 instant : `Timespan` 

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

238 """ 

239 converter = TimeConverter() 

240 nsec = converter.astropy_to_nsec(time) 

241 if nsec == converter.max_nsec - 1: 

242 raise ValueError( 

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

244 "within one ns of maximum time." 

245 ) 

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

247 

248 @property # type: ignore 

249 @cached_getter 

250 def begin(self) -> TimespanBound: 

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

252 

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

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

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

256 """ 

257 if self.isEmpty(): 

258 return self.EMPTY 

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

260 return None 

261 else: 

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

263 

264 @property # type: ignore 

265 @cached_getter 

266 def end(self) -> TimespanBound: 

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

268 

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

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

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

272 """ 

273 if self.isEmpty(): 

274 return self.EMPTY 

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

276 return None 

277 else: 

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

279 

280 def isEmpty(self) -> bool: 

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

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

283 

284 def __str__(self) -> str: 

285 if self.isEmpty(): 

286 return "(empty)" 

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

288 # simulated data in the future 

289 with warnings.catch_warnings(): 

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

291 if erfa is not None: 

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

293 if self.begin is None: 

294 head = "(-∞, " 

295 else: 

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

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

298 if self.end is None: 

299 tail = "∞)" 

300 else: 

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

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

303 return head + tail 

304 

305 def __repr__(self) -> str: 

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

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

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

309 # eval-friendly __repr__. 

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

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

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

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

314 

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

316 if not isinstance(other, Timespan): 

317 return False 

318 # Correctness of this simple implementation depends on __init__ 

319 # standardizing all empty timespans to a single value. 

320 return self._nsec == other._nsec 

321 

322 def __hash__(self) -> int: 

323 # Correctness of this simple implementation depends on __init__ 

324 # standardizing all empty timespans to a single value. 

325 return hash(self._nsec) 

326 

327 def __reduce__(self) -> tuple: 

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

329 

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

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

332 

333 Parameters 

334 ---------- 

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

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

337 

338 Returns 

339 ------- 

340 less : `bool` 

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

342 empty. 

343 """ 

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

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

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

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

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

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

350 # first term is also false. 

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

352 nsec = TimeConverter().astropy_to_nsec(other) 

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

354 else: 

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

356 

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

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

359 

360 Parameters 

361 ---------- 

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

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

364 

365 Returns 

366 ------- 

367 greater : `bool` 

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

369 empty. 

370 """ 

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

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

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

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

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

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

377 # first term is also false. 

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

379 nsec = TimeConverter().astropy_to_nsec(other) 

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

381 else: 

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

383 

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

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

386 

387 Parameters 

388 ---------- 

389 other : `Timespan` 

390 Timespan to relate to ``self``. 

391 

392 Returns 

393 ------- 

394 overlaps : `bool` 

395 The result of the overlap test. 

396 

397 Notes 

398 ----- 

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

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

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

402 """ 

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

404 

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

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

407 

408 Tests whether the intersection of this timespan with another timespan 

409 or point is equal to the other one. 

410 

411 Parameters 

412 ---------- 

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

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

415 

416 Returns 

417 ------- 

418 overlaps : `bool` 

419 The result of the contains test. 

420 

421 Notes 

422 ----- 

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

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

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

426 

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

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

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

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

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

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

433 """ 

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

435 nsec = TimeConverter().astropy_to_nsec(other) 

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

437 else: 

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

439 

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

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

442 

443 Parameters 

444 ---------- 

445 *args 

446 All positional arguments are `Timespan` instances. 

447 

448 Returns 

449 ------- 

450 intersection : `Timespan` 

451 The intersection timespan. 

452 """ 

453 if not args: 

454 return self 

455 lowers = [self._nsec[0]] 

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

457 uppers = [self._nsec[1]] 

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

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

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

461 

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

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

464 

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

466 

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

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

469 operands. 

470 

471 Parameters 

472 ---------- 

473 other : `Timespan` 

474 Timespan to subtract. 

475 

476 Yields 

477 ------ 

478 result : `Timespan` 

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

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

481 """ 

482 intersection = self.intersection(other) 

483 if intersection.isEmpty(): 

484 yield self 

485 elif intersection == self: 

486 yield from () 

487 else: 

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

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

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

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

492 

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

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

495 

496 Parameters 

497 ---------- 

498 minimal : `bool`, optional 

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

500 

501 Returns 

502 ------- 

503 simple : `list` of `int` 

504 The internal span as integer nanoseconds. 

505 """ 

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

507 return list(self._nsec) 

508 

509 @classmethod 

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

511 universe: Optional[DimensionUniverse] = None, 

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

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

514 

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

516 

517 Parameters 

518 ---------- 

519 simple : `list` of `int` 

520 The values returned by `to_simple()`. 

521 universe : `DimensionUniverse`, optional 

522 Unused. 

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

524 Unused. 

525 

526 Returns 

527 ------- 

528 result : `Timespan` 

529 Newly-constructed object. 

530 """ 

531 nsec1, nsec2 = simple # for mypy 

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

533 

534 to_json = to_json_generic 

535 from_json = classmethod(from_json_generic) 

536 

537 @classmethod 

538 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any: 

539 """Convert Timespan into YAML format. 

540 

541 This produces a scalar node with a tag "!_SpecialTimespanBound" and 

542 value being a name of _SpecialTimespanBound enum. 

543 

544 Parameters 

545 ---------- 

546 dumper : `yaml.Dumper` 

547 YAML dumper instance. 

548 timespan : `Timespan` 

549 Data to be converted. 

550 """ 

551 if timespan.isEmpty(): 

552 return dumper.represent_scalar(cls.yaml_tag, "EMPTY") 

553 else: 

554 return dumper.represent_mapping( 

555 cls.yaml_tag, 

556 dict(begin=timespan.begin, end=timespan.end), 

557 ) 

558 

559 @classmethod 

560 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.ScalarNode) -> Optional[Timespan]: 

561 """Convert YAML node into _SpecialTimespanBound. 

562 

563 Parameters 

564 ---------- 

565 loader : `yaml.SafeLoader` 

566 Instance of YAML loader class. 

567 node : `yaml.ScalarNode` 

568 YAML node. 

569 

570 Returns 

571 ------- 

572 value : `Timespan` 

573 Timespan instance, can be ``None``. 

574 """ 

575 if node.value is None: 

576 return None 

577 elif node.value == "EMPTY": 

578 return Timespan.makeEmpty() 

579 else: 

580 d = loader.construct_mapping(node) 

581 return Timespan(d["begin"], d["end"]) 

582 

583 

584# Register Timespan -> YAML conversion method with Dumper class 

585yaml.Dumper.add_representer(Timespan, Timespan.to_yaml) 

586 

587# Register YAML -> Timespan conversion method with Loader, for our use case we 

588# only need SafeLoader. 

589yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml) 

590 

591 

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

593 

594 

595class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]): 

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

597 

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

599 database engine. 

600 

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

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

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

604 

605 Notes 

606 ----- 

607 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

610 """ 

611 

612 NAME: ClassVar[str] = "timespan" 

613 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL 

614 

615 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

617 uses two separate fields for the begin (inclusive) and end (exclusive) 

618 endpoints. 

619 

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

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

622 """ 

623 

624 __slots__ = () 

625 

626 @classmethod 

627 @abstractmethod 

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

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

630 

631 Parameters 

632 ---------- 

633 timespan : `Timespan` 

634 Literal timespan to convert. 

635 

636 Returns 

637 ------- 

638 tsRepr : `TimespanDatabaseRepresentation` 

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

640 column expressions. 

641 """ 

642 raise NotImplementedError() 

643 

644 @abstractmethod 

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

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

647 

648 Returns 

649 ------- 

650 empty : `sqlalchemy.sql.ColumnElement` 

651 A boolean SQLAlchemy expression object. 

652 """ 

653 raise NotImplementedError() 

654 

655 @abstractmethod 

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

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

658 

659 Returns a SQLAlchemy expression representing a test for whether an 

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

661 point. 

662 

663 Parameters 

664 ---------- 

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

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

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

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

669 

670 Returns 

671 ------- 

672 less : `sqlalchemy.sql.ColumnElement` 

673 A boolean SQLAlchemy expression object. 

674 

675 Notes 

676 ----- 

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

678 """ 

679 raise NotImplementedError() 

680 

681 @abstractmethod 

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

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

684 

685 Returns a SQLAlchemy expression representing a test for whether an 

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

687 time point. 

688 

689 Parameters 

690 ---------- 

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

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

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

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

695 

696 Returns 

697 ------- 

698 greater : `sqlalchemy.sql.ColumnElement` 

699 A boolean SQLAlchemy expression object. 

700 

701 Notes 

702 ----- 

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

704 """ 

705 raise NotImplementedError() 

706 

707 @abstractmethod 

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

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

710 

711 Parameters 

712 ---------- 

713 other : ``type(self)`` 

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

715 

716 Returns 

717 ------- 

718 overlap : `sqlalchemy.sql.ColumnElement` 

719 A boolean SQLAlchemy expression object. 

720 

721 Notes 

722 ----- 

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

724 """ 

725 raise NotImplementedError() 

726 

727 @abstractmethod 

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

729 """Return a SQLAlchemy expression representing containment. 

730 

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

732 timespan or a time point. 

733 

734 Parameters 

735 ---------- 

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

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

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

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

740 

741 Returns 

742 ------- 

743 contains : `sqlalchemy.sql.ColumnElement` 

744 A boolean SQLAlchemy expression object. 

745 

746 Notes 

747 ----- 

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

749 """ 

750 raise NotImplementedError() 

751 

752 @abstractmethod 

753 def lower(self: _S) -> sqlalchemy.sql.ColumnElement: 

754 """Return a SQLAlchemy expression representing a lower bound of a 

755 timespan. 

756 

757 Returns 

758 ------- 

759 lower : `sqlalchemy.sql.ColumnElement` 

760 A SQLAlchemy expression for a lower bound. 

761 

762 Notes 

763 ----- 

764 If database holds ``NULL`` for a timespan then the returned expression 

765 should evaluate to 0. Main purpose of this and `upper` method is to use 

766 them in generating SQL, in particular ORDER BY clause, to guarantee a 

767 predictable ordering. It may potentially be used for transforming 

768 boolean user expressions into SQL, but it will likely require extra 

769 attention to ordering issues. 

770 """ 

771 raise NotImplementedError() 

772 

773 @abstractmethod 

774 def upper(self: _S) -> sqlalchemy.sql.ColumnElement: 

775 """Return a SQLAlchemy expression representing an upper bound of a 

776 timespan. 

777 

778 Returns 

779 ------- 

780 upper : `sqlalchemy.sql.ColumnElement` 

781 A SQLAlchemy expression for an upper bound. 

782 

783 Notes 

784 ----- 

785 If database holds ``NULL`` for a timespan then the returned expression 

786 should evaluate to 0. Also see notes for `lower` method. 

787 """ 

788 raise NotImplementedError() 

789 

790 

791class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

793 

794 An implementation of `TimespanDatabaseRepresentation` that simply stores 

795 the endpoints in two separate fields. 

796 

797 This type should generally be accessed via 

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

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

800 

801 Parameters 

802 ---------- 

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

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

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

806 nanoseconds. 

807 name : `str`, optional 

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

809 representations. Defaults to ``cls.NAME``. 

810 

811 Notes 

812 ----- 

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

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

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

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

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

818 by our integer-time mapping. 

819 """ 

820 

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

822 self._nsec = nsec 

823 self._name = name 

824 

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

826 

827 @classmethod 

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

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

830 # Docstring inherited. 

831 if name is None: 

832 name = cls.NAME 

833 return ( 

834 ddl.FieldSpec( 

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

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

837 **kwargs, 

838 ), 

839 ddl.FieldSpec( 

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

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

842 **kwargs, 

843 ), 

844 ) 

845 

846 @classmethod 

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

848 # Docstring inherited. 

849 if name is None: 

850 name = cls.NAME 

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

852 

853 @classmethod 

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

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

856 # Docstring inherited. 

857 if name is None: 

858 name = cls.NAME 

859 if result is None: 

860 result = {} 

861 if extent is None: 

862 begin_nsec = None 

863 end_nsec = None 

864 else: 

865 begin_nsec = extent._nsec[0] 

866 end_nsec = extent._nsec[1] 

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

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

869 return result 

870 

871 @classmethod 

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

873 # Docstring inherited. 

874 if name is None: 

875 name = cls.NAME 

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

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

878 if begin_nsec is None: 

879 if end_nsec is not None: 

880 raise RuntimeError( 

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

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

883 ) 

884 return None 

885 elif end_nsec is None: 

886 raise RuntimeError( 

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

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

889 ) 

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

891 

892 @classmethod 

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

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

895 # Docstring inherited. 

896 if name is None: 

897 name = cls.NAME 

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

899 name=name) 

900 

901 @classmethod 

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

903 # Docstring inherited. 

904 return cls( 

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

906 name=cls.NAME, 

907 ) 

908 

909 @property 

910 def name(self) -> str: 

911 # Docstring inherited. 

912 return self._name 

913 

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

915 # Docstring inherited. 

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

917 

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

919 # Docstring inherited. 

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

921 

922 def __lt__( 

923 self, 

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

925 ) -> sqlalchemy.sql.ColumnElement: 

926 # Docstring inherited. 

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

928 # expressions. 

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

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

931 else: 

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

933 

934 def __gt__( 

935 self, 

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

937 ) -> sqlalchemy.sql.ColumnElement: 

938 # Docstring inherited. 

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

940 # expressions. 

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

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

943 else: 

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

945 

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

947 # Docstring inherited. 

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

949 

950 def contains( 

951 self, 

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

953 ) -> sqlalchemy.sql.ColumnElement: 

954 # Docstring inherited. 

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

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

957 else: 

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

959 

960 def lower(self) -> sqlalchemy.sql.ColumnElement: 

961 # Docstring inherited. 

962 return sqlalchemy.sql.functions.coalesce(self._nsec[0], sqlalchemy.sql.literal(0)) 

963 

964 def upper(self) -> sqlalchemy.sql.ColumnElement: 

965 # Docstring inherited. 

966 return sqlalchemy.sql.functions.coalesce(self._nsec[1], sqlalchemy.sql.literal(0)) 

967 

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

969 # Docstring inherited. 

970 if name is None: 

971 yield from self._nsec 

972 else: 

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

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

975 

976 

977TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation