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

314 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-12 09:20 +0000

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 collections.abc import Generator, Mapping 

32from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union 

33 

34import astropy.time 

35import astropy.utils.exceptions 

36import sqlalchemy 

37import yaml 

38 

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

40# ErfaWarning is no longer an AstropyWarning 

41try: 

42 import erfa 

43except ImportError: 

44 erfa = None 

45 

46from lsst.utils.classes import cached_getter 

47 

48from . import ddl 

49from .json import from_json_generic, to_json_generic 

50from .time_utils import TimeConverter 

51 

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

53 from ..registry import Registry 

54 from .dimensions import DimensionUniverse 

55 

56 

57class _SpecialTimespanBound(enum.Enum): 

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

59 

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

61 `Timespan.EMPTY` alias. 

62 """ 

63 

64 EMPTY = enum.auto() 

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

66 Timespans that contain no points. 

67 """ 

68 

69 

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

71 

72 

73class Timespan: 

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

75 

76 Parameters 

77 ---------- 

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

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

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

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

82 bound is ignored. 

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

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

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

86 creates an empty timespan. 

87 padInstantaneous : `bool`, optional 

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

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

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

91 the empty timespan. 

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

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

94 `TimespanDatabaseRepresentation` implementation only. If provided, 

95 all other arguments are are ignored. 

96 

97 Raises 

98 ------ 

99 TypeError 

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

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

102 ValueError 

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

104 supported by this class. 

105 

106 Notes 

107 ----- 

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

109 

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

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

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

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

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

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

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

117 ``overlaps``. 

118 

119 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

127 preserved even if the values are not. 

128 

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

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

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

132 """ 

133 

134 def __init__( 

135 self, 

136 begin: TimespanBound, 

137 end: TimespanBound, 

138 padInstantaneous: bool = True, 

139 _nsec: tuple[int, int] | None = None, 

140 ): 

141 converter = TimeConverter() 

142 if _nsec is None: 

143 begin_nsec: int 

144 if begin is None: 

145 begin_nsec = converter.min_nsec 

146 elif begin is self.EMPTY: 

147 begin_nsec = converter.max_nsec 

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

149 begin_nsec = converter.astropy_to_nsec(begin) 

150 else: 

151 raise TypeError( 

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

153 ) 

154 end_nsec: int 

155 if end is None: 

156 end_nsec = converter.max_nsec 

157 elif end is self.EMPTY: 

158 end_nsec = converter.min_nsec 

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

160 end_nsec = converter.astropy_to_nsec(end) 

161 else: 

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

163 if begin_nsec == end_nsec: 

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

165 with warnings.catch_warnings(): 

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

167 if erfa is not None: 

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

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

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

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

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

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

174 elif padInstantaneous: 

175 end_nsec += 1 

176 if end_nsec == converter.max_nsec: 

177 raise ValueError( 

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

179 "within one ns of maximum time." 

180 ) 

181 _nsec = (begin_nsec, end_nsec) 

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

183 # Standardizing all empty timespans to the same underlying values 

184 # here simplifies all other operations (including interactions 

185 # with TimespanDatabaseRepresentation implementations). 

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

187 self._nsec = _nsec 

188 

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

190 

191 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

192 

193 # YAML tag name for Timespan 

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

195 

196 @classmethod 

197 def makeEmpty(cls) -> Timespan: 

198 """Construct an empty timespan. 

199 

200 Returns 

201 ------- 

202 empty : `Timespan` 

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

204 and overlaps no other timespans (including itself). 

205 """ 

206 converter = TimeConverter() 

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

208 

209 @classmethod 

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

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

212 

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

214 timespan. 

215 

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

217 but may be slightly more efficient. 

218 

219 Parameters 

220 ---------- 

221 time : `astropy.time.Time` 

222 Time to use for the lower bound. 

223 

224 Returns 

225 ------- 

226 instant : `Timespan` 

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

228 """ 

229 converter = TimeConverter() 

230 nsec = converter.astropy_to_nsec(time) 

231 if nsec == converter.max_nsec - 1: 

232 raise ValueError( 

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

234 ) 

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

236 

237 @property 

238 @cached_getter 

239 def begin(self) -> TimespanBound: 

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

241 

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

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

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

245 """ 

246 if self.isEmpty(): 

247 return self.EMPTY 

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

249 return None 

250 else: 

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

252 

253 @property 

254 @cached_getter 

255 def end(self) -> TimespanBound: 

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

257 

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

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

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

261 """ 

262 if self.isEmpty(): 

263 return self.EMPTY 

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

265 return None 

266 else: 

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

268 

269 def isEmpty(self) -> bool: 

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

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

272 

273 def __str__(self) -> str: 

274 if self.isEmpty(): 

275 return "(empty)" 

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

277 # simulated data in the future 

278 with warnings.catch_warnings(): 

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

280 if erfa is not None: 

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

282 if self.begin is None: 

283 head = "(-∞, " 

284 else: 

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

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

287 if self.end is None: 

288 tail = "∞)" 

289 else: 

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

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

292 return head + tail 

293 

294 def __repr__(self) -> str: 

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

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

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

298 # eval-friendly __repr__. 

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

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

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

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

303 

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

305 if not isinstance(other, Timespan): 

306 return False 

307 # Correctness of this simple implementation depends on __init__ 

308 # standardizing all empty timespans to a single value. 

309 return self._nsec == other._nsec 

310 

311 def __hash__(self) -> int: 

312 # Correctness of this simple implementation depends on __init__ 

313 # standardizing all empty timespans to a single value. 

314 return hash(self._nsec) 

315 

316 def __reduce__(self) -> tuple: 

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

318 

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

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

321 

322 Parameters 

323 ---------- 

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

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

326 

327 Returns 

328 ------- 

329 less : `bool` 

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

331 empty. 

332 """ 

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

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

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

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

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

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

339 # first term is also false. 

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

341 nsec = TimeConverter().astropy_to_nsec(other) 

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

343 else: 

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

345 

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

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

348 

349 Parameters 

350 ---------- 

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

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

353 

354 Returns 

355 ------- 

356 greater : `bool` 

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

358 empty. 

359 """ 

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

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

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

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

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

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

366 # first term is also false. 

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

368 nsec = TimeConverter().astropy_to_nsec(other) 

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

370 else: 

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

372 

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

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

375 

376 Parameters 

377 ---------- 

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

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

380 a synonym for `contains`. 

381 

382 Returns 

383 ------- 

384 overlaps : `bool` 

385 The result of the overlap test. 

386 

387 Notes 

388 ----- 

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

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

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

392 """ 

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

394 return self.contains(other) 

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

396 

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

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

399 

400 Tests whether the intersection of this timespan with another timespan 

401 or point is equal to the other one. 

402 

403 Parameters 

404 ---------- 

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

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

407 

408 Returns 

409 ------- 

410 overlaps : `bool` 

411 The result of the contains test. 

412 

413 Notes 

414 ----- 

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

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

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

418 

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

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

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

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

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

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

425 """ 

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

427 nsec = TimeConverter().astropy_to_nsec(other) 

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

429 else: 

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

431 

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

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

434 

435 Parameters 

436 ---------- 

437 *args 

438 All positional arguments are `Timespan` instances. 

439 

440 Returns 

441 ------- 

442 intersection : `Timespan` 

443 The intersection timespan. 

444 """ 

445 if not args: 

446 return self 

447 lowers = [self._nsec[0]] 

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

449 uppers = [self._nsec[1]] 

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

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

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

453 

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

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

456 

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

458 

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

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

461 operands. 

462 

463 Parameters 

464 ---------- 

465 other : `Timespan` 

466 Timespan to subtract. 

467 

468 Yields 

469 ------ 

470 result : `Timespan` 

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

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

473 """ 

474 intersection = self.intersection(other) 

475 if intersection.isEmpty(): 

476 yield self 

477 elif intersection == self: 

478 yield from () 

479 else: 

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

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

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

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

484 

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

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

487 

488 Parameters 

489 ---------- 

490 minimal : `bool`, optional 

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

492 

493 Returns 

494 ------- 

495 simple : `list` of `int` 

496 The internal span as integer nanoseconds. 

497 """ 

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

499 return list(self._nsec) 

500 

501 @classmethod 

502 def from_simple( 

503 cls, 

504 simple: list[int], 

505 universe: DimensionUniverse | None = None, 

506 registry: Registry | None = None, 

507 ) -> Timespan: 

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

509 

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

511 

512 Parameters 

513 ---------- 

514 simple : `list` of `int` 

515 The values returned by `to_simple()`. 

516 universe : `DimensionUniverse`, optional 

517 Unused. 

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

519 Unused. 

520 

521 Returns 

522 ------- 

523 result : `Timespan` 

524 Newly-constructed object. 

525 """ 

526 nsec1, nsec2 = simple # for mypy 

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

528 

529 to_json = to_json_generic 

530 from_json: ClassVar = classmethod(from_json_generic) 

531 

532 @classmethod 

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

534 """Convert Timespan into YAML format. 

535 

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

537 value being a name of _SpecialTimespanBound enum. 

538 

539 Parameters 

540 ---------- 

541 dumper : `yaml.Dumper` 

542 YAML dumper instance. 

543 timespan : `Timespan` 

544 Data to be converted. 

545 """ 

546 if timespan.isEmpty(): 

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

548 else: 

549 return dumper.represent_mapping( 

550 cls.yaml_tag, 

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

552 ) 

553 

554 @classmethod 

555 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None: 

556 """Convert YAML node into _SpecialTimespanBound. 

557 

558 Parameters 

559 ---------- 

560 loader : `yaml.SafeLoader` 

561 Instance of YAML loader class. 

562 node : `yaml.ScalarNode` 

563 YAML node. 

564 

565 Returns 

566 ------- 

567 value : `Timespan` 

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

569 """ 

570 if node.value is None: 

571 return None 

572 elif node.value == "EMPTY": 

573 return Timespan.makeEmpty() 

574 else: 

575 d = loader.construct_mapping(node) 

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

577 

578 

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

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

581 

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

583# only need SafeLoader. 

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

585 

586 

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

588 

589 

590class TimespanDatabaseRepresentation(ABC): 

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

592 

593 Notes 

594 ----- 

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

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

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

598 

599 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

602 """ 

603 

604 NAME: ClassVar[str] = "timespan" 

605 

606 Compound: ClassVar[type[TimespanDatabaseRepresentation]] 

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

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

609 endpoints. 

610 

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

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

613 """ 

614 

615 __slots__ = () 

616 

617 @classmethod 

618 @abstractmethod 

619 def makeFieldSpecs( 

620 cls, nullable: bool, name: str | None = None, **kwargs: Any 

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

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

623 

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

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

626 

627 Parameters 

628 ---------- 

629 nullable : `bool` 

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

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

632 the database are implementation-defined. Nullable timespan fields 

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

634 name : `str`, optional 

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

636 representations. Defaults to ``cls.NAME``. 

637 **kwargs 

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

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

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

641 

642 Returns 

643 ------- 

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

645 Field specification objects; length of the tuple is 

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

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

648 """ 

649 raise NotImplementedError() 

650 

651 @classmethod 

652 @abstractmethod 

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

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

655 

656 Parameters 

657 ---------- 

658 name : `str`, optional 

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

660 representations. Defaults to ``cls.NAME``. 

661 

662 Returns 

663 ------- 

664 names : `tuple` [ `str` ] 

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

666 specifications returned by `makeFieldSpecs`. 

667 """ 

668 raise NotImplementedError() 

669 

670 @classmethod 

671 @abstractmethod 

672 def fromLiteral(cls: type[_S], timespan: Timespan | None) -> _S: 

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

674 

675 Parameters 

676 ---------- 

677 timespan : `Timespan` or `None` 

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

679 timespan. 

680 

681 Returns 

682 ------- 

683 tsRepr : `TimespanDatabaseRepresentation` 

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

685 column expressions. 

686 """ 

687 raise NotImplementedError() 

688 

689 @classmethod 

690 @abstractmethod 

691 def from_columns(cls: type[_S], columns: sqlalchemy.sql.ColumnCollection, name: str | None = None) -> _S: 

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

693 subquery. 

694 

695 Parameters 

696 ---------- 

697 columns : `sqlalchemy.sql.ColumnCollections` 

698 SQLAlchemy container for raw columns. 

699 name : `str`, optional 

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

701 representations. Defaults to ``cls.NAME``. 

702 

703 Returns 

704 ------- 

705 tsRepr : `TimespanDatabaseRepresentation` 

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

707 column expressions. 

708 """ 

709 raise NotImplementedError() 

710 

711 @classmethod 

712 @abstractmethod 

713 def update( 

714 cls, timespan: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None 

715 ) -> dict[str, Any]: 

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

717 

718 Parameters 

719 ---------- 

720 timespan 

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

722 name : `str`, optional 

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

724 representations. Defaults to ``cls.NAME``. 

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

726 A dictionary representing a database row that fields should be 

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

728 

729 Returns 

730 ------- 

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

732 A dictionary containing this representation of a timespan. Exactly 

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

734 """ 

735 raise NotImplementedError() 

736 

737 @classmethod 

738 @abstractmethod 

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

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

741 

742 Parameters 

743 ---------- 

744 mapping : `~collections.abc.Mapping` [ `Any`, `Any` ] 

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

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

747 value of `getFieldNames`. 

748 name : `str`, optional 

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

750 representations. Defaults to ``cls.NAME``. 

751 

752 Returns 

753 ------- 

754 timespan : `Timespan` or `None` 

755 Python representation of the timespan. 

756 """ 

757 raise NotImplementedError() 

758 

759 @classmethod 

760 def hasExclusionConstraint(cls) -> bool: 

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

762 

763 Returns 

764 ------- 

765 supported : `bool` 

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

767 includes the fields of this representation is allowed. 

768 """ 

769 return False 

770 

771 @property 

772 @abstractmethod 

773 def name(self) -> str: 

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

775 (`str`). 

776 

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

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

779 common subset of the column names. 

780 """ 

781 raise NotImplementedError() 

782 

783 @abstractmethod 

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

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

786 

787 Returns a SQLAlchemy expression that tests whether this region is 

788 logically ``NULL``. 

789 

790 Returns 

791 ------- 

792 isnull : `sqlalchemy.sql.ColumnElement` 

793 A boolean SQLAlchemy expression object. 

794 """ 

795 raise NotImplementedError() 

796 

797 @abstractmethod 

798 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]: 

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

800 

801 Parameters 

802 ---------- 

803 name : `str`, optional 

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

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

806 be used. 

807 

808 Returns 

809 ------- 

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

811 The true column or columns that back this object. 

812 """ 

813 raise NotImplementedError() 

814 

815 @abstractmethod 

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

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

818 

819 Returns 

820 ------- 

821 empty : `sqlalchemy.sql.ColumnElement` 

822 A boolean SQLAlchemy expression object. 

823 """ 

824 raise NotImplementedError() 

825 

826 @abstractmethod 

827 def __lt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement: 

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

829 

830 Returns a SQLAlchemy expression representing a test for whether an 

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

832 point. 

833 

834 Parameters 

835 ---------- 

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

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

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

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

840 

841 Returns 

842 ------- 

843 less : `sqlalchemy.sql.ColumnElement` 

844 A boolean SQLAlchemy expression object. 

845 

846 Notes 

847 ----- 

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

849 """ 

850 raise NotImplementedError() 

851 

852 @abstractmethod 

853 def __gt__(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement: 

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

855 

856 Returns a SQLAlchemy expression representing a test for whether an 

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

858 time point. 

859 

860 Parameters 

861 ---------- 

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

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

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

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

866 

867 Returns 

868 ------- 

869 greater : `sqlalchemy.sql.ColumnElement` 

870 A boolean SQLAlchemy expression object. 

871 

872 Notes 

873 ----- 

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

875 """ 

876 raise NotImplementedError() 

877 

878 @abstractmethod 

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

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

881 

882 Parameters 

883 ---------- 

884 other : ``type(self)`` 

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

886 this is a synonym for `contains`. 

887 

888 Returns 

889 ------- 

890 overlap : `sqlalchemy.sql.ColumnElement` 

891 A boolean SQLAlchemy expression object. 

892 

893 Notes 

894 ----- 

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

896 """ 

897 raise NotImplementedError() 

898 

899 @abstractmethod 

900 def contains(self: _S, other: _S | sqlalchemy.sql.ColumnElement) -> sqlalchemy.sql.ColumnElement: 

901 """Return a SQLAlchemy expression representing containment. 

902 

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

904 timespan or a time point. 

905 

906 Parameters 

907 ---------- 

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

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

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

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

912 

913 Returns 

914 ------- 

915 contains : `sqlalchemy.sql.ColumnElement` 

916 A boolean SQLAlchemy expression object. 

917 

918 Notes 

919 ----- 

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

921 """ 

922 raise NotImplementedError() 

923 

924 @abstractmethod 

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

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

927 timespan. 

928 

929 Returns 

930 ------- 

931 lower : `sqlalchemy.sql.ColumnElement` 

932 A SQLAlchemy expression for a lower bound. 

933 

934 Notes 

935 ----- 

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

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

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

939 predictable ordering. It may potentially be used for transforming 

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

941 attention to ordering issues. 

942 """ 

943 raise NotImplementedError() 

944 

945 @abstractmethod 

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

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

948 timespan. 

949 

950 Returns 

951 ------- 

952 upper : `sqlalchemy.sql.ColumnElement` 

953 A SQLAlchemy expression for an upper bound. 

954 

955 Notes 

956 ----- 

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

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

959 """ 

960 raise NotImplementedError() 

961 

962 

963class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

965 

966 An implementation of `TimespanDatabaseRepresentation` that simply stores 

967 the endpoints in two separate fields. 

968 

969 This type should generally be accessed via 

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

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

972 

973 Parameters 

974 ---------- 

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

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

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

978 nanoseconds. 

979 name : `str`, optional 

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

981 representations. Defaults to ``cls.NAME``. 

982 

983 Notes 

984 ----- 

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

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

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

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

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

990 by our integer-time mapping. 

991 """ 

992 

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

994 self._nsec = nsec 

995 self._name = name 

996 

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

998 

999 @classmethod 

1000 def makeFieldSpecs( 

1001 cls, nullable: bool, name: str | None = None, **kwargs: Any 

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

1003 # Docstring inherited. 

1004 if name is None: 

1005 name = cls.NAME 

1006 return ( 

1007 ddl.FieldSpec( 

1008 f"{name}_begin", 

1009 dtype=sqlalchemy.BigInteger, 

1010 nullable=nullable, 

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

1012 **kwargs, 

1013 ), 

1014 ddl.FieldSpec( 

1015 f"{name}_end", 

1016 dtype=sqlalchemy.BigInteger, 

1017 nullable=nullable, 

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

1019 **kwargs, 

1020 ), 

1021 ) 

1022 

1023 @classmethod 

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

1025 # Docstring inherited. 

1026 if name is None: 

1027 name = cls.NAME 

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

1029 

1030 @classmethod 

1031 def update( 

1032 cls, extent: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None 

1033 ) -> dict[str, Any]: 

1034 # Docstring inherited. 

1035 if name is None: 

1036 name = cls.NAME 

1037 if result is None: 

1038 result = {} 

1039 if extent is None: 

1040 begin_nsec = None 

1041 end_nsec = None 

1042 else: 

1043 begin_nsec = extent._nsec[0] 

1044 end_nsec = extent._nsec[1] 

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

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

1047 return result 

1048 

1049 @classmethod 

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

1051 # Docstring inherited. 

1052 if name is None: 

1053 name = cls.NAME 

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

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

1056 if begin_nsec is None: 

1057 if end_nsec is not None: 

1058 raise RuntimeError( 

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

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

1061 ) 

1062 return None 

1063 elif end_nsec is None: 

1064 raise RuntimeError( 

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

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

1067 ) 

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

1069 

1070 @classmethod 

1071 def from_columns( 

1072 cls, columns: sqlalchemy.sql.ColumnCollection, name: str | None = None 

1073 ) -> _CompoundTimespanDatabaseRepresentation: 

1074 # Docstring inherited. 

1075 if name is None: 

1076 name = cls.NAME 

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

1078 

1079 @classmethod 

1080 def fromLiteral(cls, timespan: Timespan | None) -> _CompoundTimespanDatabaseRepresentation: 

1081 # Docstring inherited. 

1082 if timespan is None: 

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

1084 return cls( 

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

1086 name=cls.NAME, 

1087 ) 

1088 

1089 @property 

1090 def name(self) -> str: 

1091 # Docstring inherited. 

1092 return self._name 

1093 

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

1095 # Docstring inherited. 

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

1097 

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

1099 # Docstring inherited. 

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

1101 

1102 def __lt__( 

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

1104 ) -> sqlalchemy.sql.ColumnElement: 

1105 # Docstring inherited. 

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

1107 # expressions. 

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

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

1110 else: 

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

1112 

1113 def __gt__( 

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

1115 ) -> sqlalchemy.sql.ColumnElement: 

1116 # Docstring inherited. 

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

1118 # expressions. 

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

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

1121 else: 

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

1123 

1124 def overlaps( 

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

1126 ) -> sqlalchemy.sql.ColumnElement: 

1127 # Docstring inherited. 

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

1129 return self.contains(other) 

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

1131 

1132 def contains( 

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

1134 ) -> sqlalchemy.sql.ColumnElement: 

1135 # Docstring inherited. 

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

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

1138 else: 

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

1140 

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

1142 # Docstring inherited. 

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

1144 

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

1146 # Docstring inherited. 

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

1148 

1149 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement, ...]: 

1150 # Docstring inherited. 

1151 if name is None: 

1152 return self._nsec 

1153 else: 

1154 return ( 

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

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

1157 ) 

1158 

1159 

1160TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation