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

296 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-17 02:08 -0700

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 

28import enum 

29import warnings 

30from abc import abstractmethod 

31from typing import ( 

32 TYPE_CHECKING, 

33 Any, 

34 ClassVar, 

35 Dict, 

36 Generator, 

37 Iterator, 

38 List, 

39 Mapping, 

40 Optional, 

41 Tuple, 

42 Type, 

43 TypeVar, 

44 Union, 

45) 

46 

47import astropy.time 

48import astropy.utils.exceptions 

49import sqlalchemy 

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 

60 

61from . import ddl 

62from ._topology import TopologicalExtentDatabaseRepresentation, TopologicalSpace 

63from .json import from_json_generic, to_json_generic 

64from .time_utils import TimeConverter 

65 

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

67 from ..registry import Registry 

68 from .dimensions import DimensionUniverse 

69 

70 

71class _SpecialTimespanBound(enum.Enum): 

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

73 

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

75 `Timespan.EMPTY` alias. 

76 """ 

77 

78 EMPTY = enum.auto() 

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

80 Timespans that contain no points. 

81 """ 

82 

83 

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

85 

86 

87class Timespan: 

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

89 

90 Parameters 

91 ---------- 

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

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

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

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

96 bound is ignored. 

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

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

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

100 creates an empty timespan. 

101 padInstantaneous : `bool`, optional 

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

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

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

105 the empty timespan. 

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

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

108 `TimespanDatabaseRepresentation` implementation only. If provided, 

109 all other arguments are are ignored. 

110 

111 Raises 

112 ------ 

113 TypeError 

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

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

116 ValueError 

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

118 supported by this class. 

119 

120 Notes 

121 ----- 

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

123 

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

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

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

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

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

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

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

131 ``overlaps``. 

132 

133 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

141 preserved even if the values are not. 

142 

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

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

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

146 """ 

147 

148 def __init__( 

149 self, 

150 begin: TimespanBound, 

151 end: TimespanBound, 

152 padInstantaneous: bool = True, 

153 _nsec: Optional[Tuple[int, int]] = None, 

154 ): 

155 converter = TimeConverter() 

156 if _nsec is None: 

157 begin_nsec: int 

158 if begin is None: 

159 begin_nsec = converter.min_nsec 

160 elif begin is self.EMPTY: 

161 begin_nsec = converter.max_nsec 

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

163 begin_nsec = converter.astropy_to_nsec(begin) 

164 else: 

165 raise TypeError( 

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

167 ) 

168 end_nsec: int 

169 if end is None: 

170 end_nsec = converter.max_nsec 

171 elif end is self.EMPTY: 

172 end_nsec = converter.min_nsec 

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

174 end_nsec = converter.astropy_to_nsec(end) 

175 else: 

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

177 if begin_nsec == end_nsec: 

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

179 with warnings.catch_warnings(): 

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

181 if erfa is not None: 

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

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

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

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

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

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

188 elif padInstantaneous: 

189 end_nsec += 1 

190 if end_nsec == converter.max_nsec: 

191 raise ValueError( 

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

193 "within one ns of maximum time." 

194 ) 

195 _nsec = (begin_nsec, end_nsec) 

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

197 # Standardizing all empty timespans to the same underlying values 

198 # here simplifies all other operations (including interactions 

199 # with TimespanDatabaseRepresentation implementations). 

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

201 self._nsec = _nsec 

202 

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

204 

205 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

206 

207 # YAML tag name for Timespan 

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

209 

210 @classmethod 

211 def makeEmpty(cls) -> Timespan: 

212 """Construct an empty timespan. 

213 

214 Returns 

215 ------- 

216 empty : `Timespan` 

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

218 and overlaps no other timespans (including itself). 

219 """ 

220 converter = TimeConverter() 

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

222 

223 @classmethod 

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

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

226 

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

228 timespan. 

229 

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

231 but may be slightly more efficient. 

232 

233 Parameters 

234 ---------- 

235 time : `astropy.time.Time` 

236 Time to use for the lower bound. 

237 

238 Returns 

239 ------- 

240 instant : `Timespan` 

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

242 """ 

243 converter = TimeConverter() 

244 nsec = converter.astropy_to_nsec(time) 

245 if nsec == converter.max_nsec - 1: 

246 raise ValueError( 

247 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time." 

248 ) 

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

250 

251 @property # type: ignore 

252 @cached_getter 

253 def begin(self) -> TimespanBound: 

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

255 

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

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

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

259 """ 

260 if self.isEmpty(): 

261 return self.EMPTY 

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

263 return None 

264 else: 

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

266 

267 @property # type: ignore 

268 @cached_getter 

269 def end(self) -> TimespanBound: 

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

271 

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

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

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

275 """ 

276 if self.isEmpty(): 

277 return self.EMPTY 

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

279 return None 

280 else: 

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

282 

283 def isEmpty(self) -> bool: 

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

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

286 

287 def __str__(self) -> str: 

288 if self.isEmpty(): 

289 return "(empty)" 

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

291 # simulated data in the future 

292 with warnings.catch_warnings(): 

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

294 if erfa is not None: 

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

296 if self.begin is None: 

297 head = "(-∞, " 

298 else: 

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

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

301 if self.end is None: 

302 tail = "∞)" 

303 else: 

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

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

306 return head + tail 

307 

308 def __repr__(self) -> str: 

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

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

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

312 # eval-friendly __repr__. 

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

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

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

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

317 

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

319 if not isinstance(other, Timespan): 

320 return False 

321 # Correctness of this simple implementation depends on __init__ 

322 # standardizing all empty timespans to a single value. 

323 return self._nsec == other._nsec 

324 

325 def __hash__(self) -> int: 

326 # Correctness of this simple implementation depends on __init__ 

327 # standardizing all empty timespans to a single value. 

328 return hash(self._nsec) 

329 

330 def __reduce__(self) -> tuple: 

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

332 

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

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

335 

336 Parameters 

337 ---------- 

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

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

340 

341 Returns 

342 ------- 

343 less : `bool` 

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

345 empty. 

346 """ 

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

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

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

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

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

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

353 # first term is also false. 

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

355 nsec = TimeConverter().astropy_to_nsec(other) 

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

357 else: 

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

359 

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

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

362 

363 Parameters 

364 ---------- 

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

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

367 

368 Returns 

369 ------- 

370 greater : `bool` 

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

372 empty. 

373 """ 

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

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

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

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

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

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

380 # first term is also false. 

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

382 nsec = TimeConverter().astropy_to_nsec(other) 

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

384 else: 

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

386 

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

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

389 

390 Parameters 

391 ---------- 

392 other : `Timespan` 

393 Timespan to relate to ``self``. 

394 

395 Returns 

396 ------- 

397 overlaps : `bool` 

398 The result of the overlap test. 

399 

400 Notes 

401 ----- 

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

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

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

405 """ 

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

407 

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

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

410 

411 Tests whether the intersection of this timespan with another timespan 

412 or point is equal to the other one. 

413 

414 Parameters 

415 ---------- 

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

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

418 

419 Returns 

420 ------- 

421 overlaps : `bool` 

422 The result of the contains test. 

423 

424 Notes 

425 ----- 

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

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

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

429 

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

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

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

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

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

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

436 """ 

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

438 nsec = TimeConverter().astropy_to_nsec(other) 

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

440 else: 

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

442 

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

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

445 

446 Parameters 

447 ---------- 

448 *args 

449 All positional arguments are `Timespan` instances. 

450 

451 Returns 

452 ------- 

453 intersection : `Timespan` 

454 The intersection timespan. 

455 """ 

456 if not args: 

457 return self 

458 lowers = [self._nsec[0]] 

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

460 uppers = [self._nsec[1]] 

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

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

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

464 

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

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

467 

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

469 

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

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

472 operands. 

473 

474 Parameters 

475 ---------- 

476 other : `Timespan` 

477 Timespan to subtract. 

478 

479 Yields 

480 ------ 

481 result : `Timespan` 

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

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

484 """ 

485 intersection = self.intersection(other) 

486 if intersection.isEmpty(): 

487 yield self 

488 elif intersection == self: 

489 yield from () 

490 else: 

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

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

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

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

495 

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

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

498 

499 Parameters 

500 ---------- 

501 minimal : `bool`, optional 

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

503 

504 Returns 

505 ------- 

506 simple : `list` of `int` 

507 The internal span as integer nanoseconds. 

508 """ 

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

510 return list(self._nsec) 

511 

512 @classmethod 

513 def from_simple( 

514 cls, 

515 simple: List[int], 

516 universe: Optional[DimensionUniverse] = None, 

517 registry: Optional[Registry] = None, 

518 ) -> Timespan: 

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

520 

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

522 

523 Parameters 

524 ---------- 

525 simple : `list` of `int` 

526 The values returned by `to_simple()`. 

527 universe : `DimensionUniverse`, optional 

528 Unused. 

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

530 Unused. 

531 

532 Returns 

533 ------- 

534 result : `Timespan` 

535 Newly-constructed object. 

536 """ 

537 nsec1, nsec2 = simple # for mypy 

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

539 

540 to_json = to_json_generic 

541 from_json = classmethod(from_json_generic) 

542 

543 @classmethod 

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

545 """Convert Timespan into YAML format. 

546 

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

548 value being a name of _SpecialTimespanBound enum. 

549 

550 Parameters 

551 ---------- 

552 dumper : `yaml.Dumper` 

553 YAML dumper instance. 

554 timespan : `Timespan` 

555 Data to be converted. 

556 """ 

557 if timespan.isEmpty(): 

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

559 else: 

560 return dumper.represent_mapping( 

561 cls.yaml_tag, 

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

563 ) 

564 

565 @classmethod 

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

567 """Convert YAML node into _SpecialTimespanBound. 

568 

569 Parameters 

570 ---------- 

571 loader : `yaml.SafeLoader` 

572 Instance of YAML loader class. 

573 node : `yaml.ScalarNode` 

574 YAML node. 

575 

576 Returns 

577 ------- 

578 value : `Timespan` 

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

580 """ 

581 if node.value is None: 

582 return None 

583 elif node.value == "EMPTY": 

584 return Timespan.makeEmpty() 

585 else: 

586 d = loader.construct_mapping(node) 

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

588 

589 

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

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

592 

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

594# only need SafeLoader. 

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

596 

597 

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

599 

600 

601class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation[Timespan]): 

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

603 

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

605 database engine. 

606 

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

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

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

610 

611 Notes 

612 ----- 

613 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

616 """ 

617 

618 NAME: ClassVar[str] = "timespan" 

619 SPACE: ClassVar[TopologicalSpace] = TopologicalSpace.TEMPORAL 

620 

621 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

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

624 endpoints. 

625 

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

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

628 """ 

629 

630 __slots__ = () 

631 

632 @classmethod 

633 @abstractmethod 

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

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

636 

637 Parameters 

638 ---------- 

639 timespan : `Timespan` 

640 Literal timespan to convert. 

641 

642 Returns 

643 ------- 

644 tsRepr : `TimespanDatabaseRepresentation` 

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

646 column expressions. 

647 """ 

648 raise NotImplementedError() 

649 

650 @abstractmethod 

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

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

653 

654 Returns 

655 ------- 

656 empty : `sqlalchemy.sql.ColumnElement` 

657 A boolean SQLAlchemy expression object. 

658 """ 

659 raise NotImplementedError() 

660 

661 @abstractmethod 

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

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

664 

665 Returns a SQLAlchemy expression representing a test for whether an 

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

667 point. 

668 

669 Parameters 

670 ---------- 

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

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

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

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

675 

676 Returns 

677 ------- 

678 less : `sqlalchemy.sql.ColumnElement` 

679 A boolean SQLAlchemy expression object. 

680 

681 Notes 

682 ----- 

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

684 """ 

685 raise NotImplementedError() 

686 

687 @abstractmethod 

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

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

690 

691 Returns a SQLAlchemy expression representing a test for whether an 

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

693 time point. 

694 

695 Parameters 

696 ---------- 

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

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

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

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

701 

702 Returns 

703 ------- 

704 greater : `sqlalchemy.sql.ColumnElement` 

705 A boolean SQLAlchemy expression object. 

706 

707 Notes 

708 ----- 

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

710 """ 

711 raise NotImplementedError() 

712 

713 @abstractmethod 

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

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

716 

717 Parameters 

718 ---------- 

719 other : ``type(self)`` 

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

721 

722 Returns 

723 ------- 

724 overlap : `sqlalchemy.sql.ColumnElement` 

725 A boolean SQLAlchemy expression object. 

726 

727 Notes 

728 ----- 

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

730 """ 

731 raise NotImplementedError() 

732 

733 @abstractmethod 

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

735 """Return a SQLAlchemy expression representing containment. 

736 

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

738 timespan or a time point. 

739 

740 Parameters 

741 ---------- 

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

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

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

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

746 

747 Returns 

748 ------- 

749 contains : `sqlalchemy.sql.ColumnElement` 

750 A boolean SQLAlchemy expression object. 

751 

752 Notes 

753 ----- 

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

755 """ 

756 raise NotImplementedError() 

757 

758 @abstractmethod 

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

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

761 timespan. 

762 

763 Returns 

764 ------- 

765 lower : `sqlalchemy.sql.ColumnElement` 

766 A SQLAlchemy expression for a lower bound. 

767 

768 Notes 

769 ----- 

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

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

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

773 predictable ordering. It may potentially be used for transforming 

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

775 attention to ordering issues. 

776 """ 

777 raise NotImplementedError() 

778 

779 @abstractmethod 

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

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

782 timespan. 

783 

784 Returns 

785 ------- 

786 upper : `sqlalchemy.sql.ColumnElement` 

787 A SQLAlchemy expression for an upper bound. 

788 

789 Notes 

790 ----- 

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

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

793 """ 

794 raise NotImplementedError() 

795 

796 

797class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

799 

800 An implementation of `TimespanDatabaseRepresentation` that simply stores 

801 the endpoints in two separate fields. 

802 

803 This type should generally be accessed via 

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

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

806 

807 Parameters 

808 ---------- 

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

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

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

812 nanoseconds. 

813 name : `str`, optional 

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

815 representations. Defaults to ``cls.NAME``. 

816 

817 Notes 

818 ----- 

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

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

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

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

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

824 by our integer-time mapping. 

825 """ 

826 

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

828 self._nsec = nsec 

829 self._name = name 

830 

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

832 

833 @classmethod 

834 def makeFieldSpecs( 

835 cls, nullable: bool, name: Optional[str] = None, **kwargs: Any 

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

837 # Docstring inherited. 

838 if name is None: 

839 name = cls.NAME 

840 return ( 

841 ddl.FieldSpec( 

842 f"{name}_begin", 

843 dtype=sqlalchemy.BigInteger, 

844 nullable=nullable, 

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

846 **kwargs, 

847 ), 

848 ddl.FieldSpec( 

849 f"{name}_end", 

850 dtype=sqlalchemy.BigInteger, 

851 nullable=nullable, 

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

853 **kwargs, 

854 ), 

855 ) 

856 

857 @classmethod 

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

859 # Docstring inherited. 

860 if name is None: 

861 name = cls.NAME 

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

863 

864 @classmethod 

865 def update( 

866 cls, extent: Optional[Timespan], name: Optional[str] = None, result: Optional[Dict[str, Any]] = None 

867 ) -> Dict[str, Any]: 

868 # Docstring inherited. 

869 if name is None: 

870 name = cls.NAME 

871 if result is None: 

872 result = {} 

873 if extent is None: 

874 begin_nsec = None 

875 end_nsec = None 

876 else: 

877 begin_nsec = extent._nsec[0] 

878 end_nsec = extent._nsec[1] 

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

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

881 return result 

882 

883 @classmethod 

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

885 # Docstring inherited. 

886 if name is None: 

887 name = cls.NAME 

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

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

890 if begin_nsec is None: 

891 if end_nsec is not None: 

892 raise RuntimeError( 

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

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

895 ) 

896 return None 

897 elif end_nsec is None: 

898 raise RuntimeError( 

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

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

901 ) 

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

903 

904 @classmethod 

905 def fromSelectable( 

906 cls, selectable: sqlalchemy.sql.FromClause, name: Optional[str] = None 

907 ) -> _CompoundTimespanDatabaseRepresentation: 

908 # Docstring inherited. 

909 if name is None: 

910 name = cls.NAME 

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

912 

913 @classmethod 

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

915 # Docstring inherited. 

916 return cls( 

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

918 name=cls.NAME, 

919 ) 

920 

921 @property 

922 def name(self) -> str: 

923 # Docstring inherited. 

924 return self._name 

925 

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

927 # Docstring inherited. 

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

929 

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

931 # Docstring inherited. 

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

933 

934 def __lt__( 

935 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

936 ) -> sqlalchemy.sql.ColumnElement: 

937 # Docstring inherited. 

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

939 # expressions. 

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

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

942 else: 

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

944 

945 def __gt__( 

946 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

947 ) -> sqlalchemy.sql.ColumnElement: 

948 # Docstring inherited. 

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

950 # expressions. 

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

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

953 else: 

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

955 

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

957 # Docstring inherited. 

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

959 

960 def contains( 

961 self, other: Union[_CompoundTimespanDatabaseRepresentation, sqlalchemy.sql.ColumnElement] 

962 ) -> sqlalchemy.sql.ColumnElement: 

963 # Docstring inherited. 

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

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

966 else: 

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

968 

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

970 # Docstring inherited. 

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

972 

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

974 # Docstring inherited. 

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

976 

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

978 # Docstring inherited. 

979 if name is None: 

980 yield from self._nsec 

981 else: 

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

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

984 

985 

986TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation