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

313 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-07 00:58 -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 ABC, abstractmethod 

31from typing import ( 

32 TYPE_CHECKING, 

33 Any, 

34 ClassVar, 

35 Dict, 

36 Generator, 

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 yaml 

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 lsst.utils.classes import cached_getter 

59 

60from . import ddl 

61from .json import from_json_generic, to_json_generic 

62from .time_utils import TimeConverter 

63 

64if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 

65 from ..registry import Registry 

66 from .dimensions import DimensionUniverse 

67 

68 

69class _SpecialTimespanBound(enum.Enum): 

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

71 

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

73 `Timespan.EMPTY` alias. 

74 """ 

75 

76 EMPTY = enum.auto() 

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

78 Timespans that contain no points. 

79 """ 

80 

81 

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

83 

84 

85class Timespan: 

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

87 

88 Parameters 

89 ---------- 

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

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

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

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

94 bound is ignored. 

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

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

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

98 creates an empty timespan. 

99 padInstantaneous : `bool`, optional 

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

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

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

103 the empty timespan. 

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

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

106 `TimespanDatabaseRepresentation` implementation only. If provided, 

107 all other arguments are are ignored. 

108 

109 Raises 

110 ------ 

111 TypeError 

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

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

114 ValueError 

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

116 supported by this class. 

117 

118 Notes 

119 ----- 

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

121 

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

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

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

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

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

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

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

129 ``overlaps``. 

130 

131 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

139 preserved even if the values are not. 

140 

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

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

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

144 """ 

145 

146 def __init__( 

147 self, 

148 begin: TimespanBound, 

149 end: TimespanBound, 

150 padInstantaneous: bool = True, 

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

152 ): 

153 converter = TimeConverter() 

154 if _nsec is None: 

155 begin_nsec: int 

156 if begin is None: 

157 begin_nsec = converter.min_nsec 

158 elif begin is self.EMPTY: 

159 begin_nsec = converter.max_nsec 

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

161 begin_nsec = converter.astropy_to_nsec(begin) 

162 else: 

163 raise TypeError( 

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

165 ) 

166 end_nsec: int 

167 if end is None: 

168 end_nsec = converter.max_nsec 

169 elif end is self.EMPTY: 

170 end_nsec = converter.min_nsec 

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

172 end_nsec = converter.astropy_to_nsec(end) 

173 else: 

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

175 if begin_nsec == end_nsec: 

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

177 with warnings.catch_warnings(): 

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

179 if erfa is not None: 

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

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

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

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

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

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

186 elif padInstantaneous: 

187 end_nsec += 1 

188 if end_nsec == converter.max_nsec: 

189 raise ValueError( 

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

191 "within one ns of maximum time." 

192 ) 

193 _nsec = (begin_nsec, end_nsec) 

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

195 # Standardizing all empty timespans to the same underlying values 

196 # here simplifies all other operations (including interactions 

197 # with TimespanDatabaseRepresentation implementations). 

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

199 self._nsec = _nsec 

200 

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

202 

203 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

204 

205 # YAML tag name for Timespan 

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

207 

208 @classmethod 

209 def makeEmpty(cls) -> Timespan: 

210 """Construct an empty timespan. 

211 

212 Returns 

213 ------- 

214 empty : `Timespan` 

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

216 and overlaps no other timespans (including itself). 

217 """ 

218 converter = TimeConverter() 

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

220 

221 @classmethod 

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

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

224 

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

226 timespan. 

227 

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

229 but may be slightly more efficient. 

230 

231 Parameters 

232 ---------- 

233 time : `astropy.time.Time` 

234 Time to use for the lower bound. 

235 

236 Returns 

237 ------- 

238 instant : `Timespan` 

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

240 """ 

241 converter = TimeConverter() 

242 nsec = converter.astropy_to_nsec(time) 

243 if nsec == converter.max_nsec - 1: 

244 raise ValueError( 

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

246 ) 

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

248 

249 @property 

250 @cached_getter 

251 def begin(self) -> TimespanBound: 

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

253 

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

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

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

257 """ 

258 if self.isEmpty(): 

259 return self.EMPTY 

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

261 return None 

262 else: 

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

264 

265 @property 

266 @cached_getter 

267 def end(self) -> TimespanBound: 

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

269 

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

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

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

273 """ 

274 if self.isEmpty(): 

275 return self.EMPTY 

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

277 return None 

278 else: 

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

280 

281 def isEmpty(self) -> bool: 

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

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

284 

285 def __str__(self) -> str: 

286 if self.isEmpty(): 

287 return "(empty)" 

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

289 # simulated data in the future 

290 with warnings.catch_warnings(): 

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

292 if erfa is not None: 

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

294 if self.begin is None: 

295 head = "(-∞, " 

296 else: 

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

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

299 if self.end is None: 

300 tail = "∞)" 

301 else: 

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

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

304 return head + tail 

305 

306 def __repr__(self) -> str: 

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

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

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

310 # eval-friendly __repr__. 

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

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

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

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

315 

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

317 if not isinstance(other, Timespan): 

318 return False 

319 # Correctness of this simple implementation depends on __init__ 

320 # standardizing all empty timespans to a single value. 

321 return self._nsec == other._nsec 

322 

323 def __hash__(self) -> int: 

324 # Correctness of this simple implementation depends on __init__ 

325 # standardizing all empty timespans to a single value. 

326 return hash(self._nsec) 

327 

328 def __reduce__(self) -> tuple: 

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

330 

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

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

333 

334 Parameters 

335 ---------- 

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

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

338 

339 Returns 

340 ------- 

341 less : `bool` 

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

343 empty. 

344 """ 

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

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

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

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

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

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

351 # first term is also false. 

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

353 nsec = TimeConverter().astropy_to_nsec(other) 

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

355 else: 

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

357 

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

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

360 

361 Parameters 

362 ---------- 

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

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

365 

366 Returns 

367 ------- 

368 greater : `bool` 

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

370 empty. 

371 """ 

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

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

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

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

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

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

378 # first term is also false. 

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

380 nsec = TimeConverter().astropy_to_nsec(other) 

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

382 else: 

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

384 

385 def overlaps(self, other: Timespan | astropy.time.Time) -> bool: 

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

387 

388 Parameters 

389 ---------- 

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

391 Timespan or time to relate to ``self``. If a single time, this is 

392 a synonym for `contains`. 

393 

394 Returns 

395 ------- 

396 overlaps : `bool` 

397 The result of the overlap test. 

398 

399 Notes 

400 ----- 

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

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

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

404 """ 

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

406 return self.contains(other) 

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

408 

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

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

411 

412 Tests whether the intersection of this timespan with another timespan 

413 or point is equal to the other one. 

414 

415 Parameters 

416 ---------- 

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

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

419 

420 Returns 

421 ------- 

422 overlaps : `bool` 

423 The result of the contains test. 

424 

425 Notes 

426 ----- 

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

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

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

430 

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

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

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

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

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

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

437 """ 

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

439 nsec = TimeConverter().astropy_to_nsec(other) 

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

441 else: 

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

443 

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

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

446 

447 Parameters 

448 ---------- 

449 *args 

450 All positional arguments are `Timespan` instances. 

451 

452 Returns 

453 ------- 

454 intersection : `Timespan` 

455 The intersection timespan. 

456 """ 

457 if not args: 

458 return self 

459 lowers = [self._nsec[0]] 

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

461 uppers = [self._nsec[1]] 

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

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

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

465 

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

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

468 

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

470 

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

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

473 operands. 

474 

475 Parameters 

476 ---------- 

477 other : `Timespan` 

478 Timespan to subtract. 

479 

480 Yields 

481 ------ 

482 result : `Timespan` 

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

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

485 """ 

486 intersection = self.intersection(other) 

487 if intersection.isEmpty(): 

488 yield self 

489 elif intersection == self: 

490 yield from () 

491 else: 

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

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

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

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

496 

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

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

499 

500 Parameters 

501 ---------- 

502 minimal : `bool`, optional 

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

504 

505 Returns 

506 ------- 

507 simple : `list` of `int` 

508 The internal span as integer nanoseconds. 

509 """ 

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

511 return list(self._nsec) 

512 

513 @classmethod 

514 def from_simple( 

515 cls, 

516 simple: List[int], 

517 universe: Optional[DimensionUniverse] = None, 

518 registry: Optional[Registry] = None, 

519 ) -> Timespan: 

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

521 

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

523 

524 Parameters 

525 ---------- 

526 simple : `list` of `int` 

527 The values returned by `to_simple()`. 

528 universe : `DimensionUniverse`, optional 

529 Unused. 

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

531 Unused. 

532 

533 Returns 

534 ------- 

535 result : `Timespan` 

536 Newly-constructed object. 

537 """ 

538 nsec1, nsec2 = simple # for mypy 

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

540 

541 to_json = to_json_generic 

542 from_json = classmethod(from_json_generic) 

543 

544 @classmethod 

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

546 """Convert Timespan into YAML format. 

547 

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

549 value being a name of _SpecialTimespanBound enum. 

550 

551 Parameters 

552 ---------- 

553 dumper : `yaml.Dumper` 

554 YAML dumper instance. 

555 timespan : `Timespan` 

556 Data to be converted. 

557 """ 

558 if timespan.isEmpty(): 

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

560 else: 

561 return dumper.represent_mapping( 

562 cls.yaml_tag, 

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

564 ) 

565 

566 @classmethod 

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

568 """Convert YAML node into _SpecialTimespanBound. 

569 

570 Parameters 

571 ---------- 

572 loader : `yaml.SafeLoader` 

573 Instance of YAML loader class. 

574 node : `yaml.ScalarNode` 

575 YAML node. 

576 

577 Returns 

578 ------- 

579 value : `Timespan` 

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

581 """ 

582 if node.value is None: 

583 return None 

584 elif node.value == "EMPTY": 

585 return Timespan.makeEmpty() 

586 else: 

587 d = loader.construct_mapping(node) 

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

589 

590 

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

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

593 

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

595# only need SafeLoader. 

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

597 

598 

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

600 

601 

602class TimespanDatabaseRepresentation(ABC): 

603 """An interface for representing a timespan in a database. 

604 

605 Notes 

606 ----- 

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

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

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

610 

611 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

614 """ 

615 

616 NAME: ClassVar[str] = "timespan" 

617 

618 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

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

621 endpoints. 

622 

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

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

625 """ 

626 

627 __slots__ = () 

628 

629 @classmethod 

630 @abstractmethod 

631 def makeFieldSpecs( 

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

633 ) -> tuple[ddl.FieldSpec, ...]: 

634 """Make objects that reflect the fields that must be added to table. 

635 

636 Makes one or more `ddl.FieldSpec` objects that reflect the fields 

637 that must be added to a table for this representation. 

638 

639 Parameters 

640 ---------- 

641 nullable : `bool` 

642 If `True`, the timespan is permitted to be logically ``NULL`` 

643 (mapped to `None` in Python), though the corresponding value(s) in 

644 the database are implementation-defined. Nullable timespan fields 

645 default to NULL, while others default to (-∞, ∞). 

646 name : `str`, optional 

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

648 representations. Defaults to ``cls.NAME``. 

649 **kwargs 

650 Keyword arguments are forwarded to the `ddl.FieldSpec` constructor 

651 for all fields; implementations only provide the ``name``, 

652 ``dtype``, and ``default`` arguments themselves. 

653 

654 Returns 

655 ------- 

656 specs : `tuple` [ `ddl.FieldSpec` ] 

657 Field specification objects; length of the tuple is 

658 subclass-dependent, but is guaranteed to match the length of the 

659 return values of `getFieldNames` and `update`. 

660 """ 

661 raise NotImplementedError() 

662 

663 @classmethod 

664 @abstractmethod 

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

666 """Return the actual field names used by this representation. 

667 

668 Parameters 

669 ---------- 

670 name : `str`, optional 

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

672 representations. Defaults to ``cls.NAME``. 

673 

674 Returns 

675 ------- 

676 names : `tuple` [ `str` ] 

677 Field name(s). Guaranteed to be the same as the names of the field 

678 specifications returned by `makeFieldSpecs`. 

679 """ 

680 raise NotImplementedError() 

681 

682 @classmethod 

683 @abstractmethod 

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

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

686 

687 Parameters 

688 ---------- 

689 timespan : `Timespan` or `None` 

690 Literal timespan to convert, or `None` to make logically ``NULL`` 

691 timespan. 

692 

693 Returns 

694 ------- 

695 tsRepr : `TimespanDatabaseRepresentation` 

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

697 column expressions. 

698 """ 

699 raise NotImplementedError() 

700 

701 @classmethod 

702 @abstractmethod 

703 def from_columns( 

704 cls: Type[_S], columns: sqlalchemy.sql.ColumnCollection, name: Optional[str] = None 

705 ) -> _S: 

706 """Construct a database timespan from the columns of a table or 

707 subquery. 

708 

709 Parameters 

710 ---------- 

711 columns : `sqlalchemy.sql.ColumnCollections` 

712 SQLAlchemy container for raw columns. 

713 name : `str`, optional 

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

715 representations. Defaults to ``cls.NAME``. 

716 

717 Returns 

718 ------- 

719 tsRepr : `TimespanDatabaseRepresentation` 

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

721 column expressions. 

722 """ 

723 raise NotImplementedError() 

724 

725 @classmethod 

726 @abstractmethod 

727 def update( 

728 cls, timespan: Optional[Timespan], name: Optional[str] = None, result: Optional[Dict[str, Any]] = None 

729 ) -> Dict[str, Any]: 

730 """Add a timespan value to a dictionary that represents a database row. 

731 

732 Parameters 

733 ---------- 

734 timespan 

735 A timespan literal, or `None` for ``NULL``. 

736 name : `str`, optional 

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

738 representations. Defaults to ``cls.NAME``. 

739 result : `dict` [ `str`, `Any` ], optional 

740 A dictionary representing a database row that fields should be 

741 added to, or `None` to create and return a new one. 

742 

743 Returns 

744 ------- 

745 result : `dict` [ `str`, `Any` ] 

746 A dictionary containing this representation of a timespan. Exactly 

747 the `dict` passed as ``result`` if that is not `None`. 

748 """ 

749 raise NotImplementedError() 

750 

751 @classmethod 

752 @abstractmethod 

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

754 """Extract a timespan from a dictionary that represents a database row. 

755 

756 Parameters 

757 ---------- 

758 mapping : `Mapping` [ `str`, `Any` ] 

759 A dictionary representing a database row containing a `Timespan` 

760 in this representation. Should have key(s) equal to the return 

761 value of `getFieldNames`. 

762 name : `str`, optional 

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

764 representations. Defaults to ``cls.NAME``. 

765 

766 Returns 

767 ------- 

768 timespan : `Timespan` or `None` 

769 Python representation of the timespan. 

770 """ 

771 raise NotImplementedError() 

772 

773 @classmethod 

774 def hasExclusionConstraint(cls) -> bool: 

775 """Return `True` if this representation supports exclusion constraints. 

776 

777 Returns 

778 ------- 

779 supported : `bool` 

780 If `True`, defining a constraint via `ddl.TableSpec.exclusion` that 

781 includes the fields of this representation is allowed. 

782 """ 

783 return False 

784 

785 @property 

786 @abstractmethod 

787 def name(self) -> str: 

788 """Return base logical name for the timespan column or expression 

789 (`str`). 

790 

791 If the representation uses only one actual column, this should be the 

792 full name of the column. In other cases it is an unspecified 

793 common subset of the column names. 

794 """ 

795 raise NotImplementedError() 

796 

797 @abstractmethod 

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

799 """Return expression that tests whether the timespan is ``NULL``. 

800 

801 Returns a SQLAlchemy expression that tests whether this region is 

802 logically ``NULL``. 

803 

804 Returns 

805 ------- 

806 isnull : `sqlalchemy.sql.ColumnElement` 

807 A boolean SQLAlchemy expression object. 

808 """ 

809 raise NotImplementedError() 

810 

811 @abstractmethod 

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

813 """Return the actual column(s) that comprise this logical column. 

814 

815 Parameters 

816 ---------- 

817 name : `str`, optional 

818 If provided, a name for the logical column that should be used to 

819 label the columns. If not provided, the columns' native names will 

820 be used. 

821 

822 Returns 

823 ------- 

824 columns : `tuple` [ `sqlalchemy.sql.ColumnElement` ] 

825 The true column or columns that back this object. 

826 """ 

827 raise NotImplementedError() 

828 

829 @abstractmethod 

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

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

832 

833 Returns 

834 ------- 

835 empty : `sqlalchemy.sql.ColumnElement` 

836 A boolean SQLAlchemy expression object. 

837 """ 

838 raise NotImplementedError() 

839 

840 @abstractmethod 

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

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

843 

844 Returns a SQLAlchemy expression representing a test for whether an 

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

846 point. 

847 

848 Parameters 

849 ---------- 

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

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

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

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

854 

855 Returns 

856 ------- 

857 less : `sqlalchemy.sql.ColumnElement` 

858 A boolean SQLAlchemy expression object. 

859 

860 Notes 

861 ----- 

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

863 """ 

864 raise NotImplementedError() 

865 

866 @abstractmethod 

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

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

869 

870 Returns a SQLAlchemy expression representing a test for whether an 

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

872 time point. 

873 

874 Parameters 

875 ---------- 

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

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

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

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

880 

881 Returns 

882 ------- 

883 greater : `sqlalchemy.sql.ColumnElement` 

884 A boolean SQLAlchemy expression object. 

885 

886 Notes 

887 ----- 

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

889 """ 

890 raise NotImplementedError() 

891 

892 @abstractmethod 

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

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

895 

896 Parameters 

897 ---------- 

898 other : ``type(self)`` 

899 The timespan or time to overlap ``self`` with. If a single time, 

900 this is a synonym for `contains`. 

901 

902 Returns 

903 ------- 

904 overlap : `sqlalchemy.sql.ColumnElement` 

905 A boolean SQLAlchemy expression object. 

906 

907 Notes 

908 ----- 

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

910 """ 

911 raise NotImplementedError() 

912 

913 @abstractmethod 

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

915 """Return a SQLAlchemy expression representing containment. 

916 

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

918 timespan or a time point. 

919 

920 Parameters 

921 ---------- 

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

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

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

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

926 

927 Returns 

928 ------- 

929 contains : `sqlalchemy.sql.ColumnElement` 

930 A boolean SQLAlchemy expression object. 

931 

932 Notes 

933 ----- 

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

935 """ 

936 raise NotImplementedError() 

937 

938 @abstractmethod 

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

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

941 timespan. 

942 

943 Returns 

944 ------- 

945 lower : `sqlalchemy.sql.ColumnElement` 

946 A SQLAlchemy expression for a lower bound. 

947 

948 Notes 

949 ----- 

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

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

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

953 predictable ordering. It may potentially be used for transforming 

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

955 attention to ordering issues. 

956 """ 

957 raise NotImplementedError() 

958 

959 @abstractmethod 

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

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

962 timespan. 

963 

964 Returns 

965 ------- 

966 upper : `sqlalchemy.sql.ColumnElement` 

967 A SQLAlchemy expression for an upper bound. 

968 

969 Notes 

970 ----- 

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

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

973 """ 

974 raise NotImplementedError() 

975 

976 

977class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

979 

980 An implementation of `TimespanDatabaseRepresentation` that simply stores 

981 the endpoints in two separate fields. 

982 

983 This type should generally be accessed via 

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

985 via the `from_columns` and `fromLiteral` methods. 

986 

987 Parameters 

988 ---------- 

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

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

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

992 nanoseconds. 

993 name : `str`, optional 

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

995 representations. Defaults to ``cls.NAME``. 

996 

997 Notes 

998 ----- 

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

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

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

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

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

1004 by our integer-time mapping. 

1005 """ 

1006 

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

1008 self._nsec = nsec 

1009 self._name = name 

1010 

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

1012 

1013 @classmethod 

1014 def makeFieldSpecs( 

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

1016 ) -> tuple[ddl.FieldSpec, ...]: 

1017 # Docstring inherited. 

1018 if name is None: 

1019 name = cls.NAME 

1020 return ( 

1021 ddl.FieldSpec( 

1022 f"{name}_begin", 

1023 dtype=sqlalchemy.BigInteger, 

1024 nullable=nullable, 

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

1026 **kwargs, 

1027 ), 

1028 ddl.FieldSpec( 

1029 f"{name}_end", 

1030 dtype=sqlalchemy.BigInteger, 

1031 nullable=nullable, 

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

1033 **kwargs, 

1034 ), 

1035 ) 

1036 

1037 @classmethod 

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

1039 # Docstring inherited. 

1040 if name is None: 

1041 name = cls.NAME 

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

1043 

1044 @classmethod 

1045 def update( 

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

1047 ) -> Dict[str, Any]: 

1048 # Docstring inherited. 

1049 if name is None: 

1050 name = cls.NAME 

1051 if result is None: 

1052 result = {} 

1053 if extent is None: 

1054 begin_nsec = None 

1055 end_nsec = None 

1056 else: 

1057 begin_nsec = extent._nsec[0] 

1058 end_nsec = extent._nsec[1] 

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

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

1061 return result 

1062 

1063 @classmethod 

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

1065 # Docstring inherited. 

1066 if name is None: 

1067 name = cls.NAME 

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

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

1070 if begin_nsec is None: 

1071 if end_nsec is not None: 

1072 raise RuntimeError( 

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

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

1075 ) 

1076 return None 

1077 elif end_nsec is None: 

1078 raise RuntimeError( 

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

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

1081 ) 

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

1083 

1084 @classmethod 

1085 def from_columns( 

1086 cls, columns: sqlalchemy.sql.ColumnCollection, name: Optional[str] = None 

1087 ) -> _CompoundTimespanDatabaseRepresentation: 

1088 # Docstring inherited. 

1089 if name is None: 

1090 name = cls.NAME 

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

1092 

1093 @classmethod 

1094 def fromLiteral(cls, timespan: Optional[Timespan]) -> _CompoundTimespanDatabaseRepresentation: 

1095 # Docstring inherited. 

1096 if timespan is None: 

1097 return cls(nsec=(sqlalchemy.sql.null(), sqlalchemy.sql.null()), name=cls.NAME) 

1098 return cls( 

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

1100 name=cls.NAME, 

1101 ) 

1102 

1103 @property 

1104 def name(self) -> str: 

1105 # Docstring inherited. 

1106 return self._name 

1107 

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

1109 # Docstring inherited. 

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

1111 

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

1113 # Docstring inherited. 

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

1115 

1116 def __lt__( 

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

1118 ) -> sqlalchemy.sql.ColumnElement: 

1119 # Docstring inherited. 

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

1121 # expressions. 

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

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

1124 else: 

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

1126 

1127 def __gt__( 

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

1129 ) -> sqlalchemy.sql.ColumnElement: 

1130 # Docstring inherited. 

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

1132 # expressions. 

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

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

1135 else: 

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

1137 

1138 def overlaps( 

1139 self, other: _CompoundTimespanDatabaseRepresentation | sqlalchemy.sql.ColumnElement 

1140 ) -> sqlalchemy.sql.ColumnElement: 

1141 # Docstring inherited. 

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

1143 return self.contains(other) 

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

1145 

1146 def contains( 

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

1148 ) -> sqlalchemy.sql.ColumnElement: 

1149 # Docstring inherited. 

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

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

1152 else: 

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

1154 

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

1156 # Docstring inherited. 

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

1158 

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

1160 # Docstring inherited. 

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

1162 

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

1164 # Docstring inherited. 

1165 if name is None: 

1166 return self._nsec 

1167 else: 

1168 return ( 

1169 self._nsec[0].label(f"{name}_begin"), 

1170 self._nsec[1].label(f"{name}_end"), 

1171 ) 

1172 

1173 

1174TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation