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

314 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-27 09:44 +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 software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27from __future__ import annotations 

28 

29__all__ = ( 

30 "Timespan", 

31 "TimespanDatabaseRepresentation", 

32) 

33 

34import enum 

35import warnings 

36from abc import ABC, abstractmethod 

37from collections.abc import Generator, Mapping 

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

39 

40import astropy.time 

41import astropy.utils.exceptions 

42import sqlalchemy 

43import yaml 

44 

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

46# ErfaWarning is no longer an AstropyWarning 

47try: 

48 import erfa 

49except ImportError: 

50 erfa = None 

51 

52from lsst.utils.classes import cached_getter 

53 

54from . import ddl 

55from .json import from_json_generic, to_json_generic 

56from .time_utils import TimeConverter 

57 

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

59 from .dimensions import DimensionUniverse 

60 from .registry import Registry 

61 

62 

63class _SpecialTimespanBound(enum.Enum): 

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

65 

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

67 `Timespan.EMPTY` alias. 

68 """ 

69 

70 EMPTY = enum.auto() 

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

72 Timespans that contain no points. 

73 """ 

74 

75 

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

77 

78 

79class Timespan: 

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

81 

82 Parameters 

83 ---------- 

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

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

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

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

88 bound is ignored. 

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

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

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

92 creates an empty timespan. 

93 padInstantaneous : `bool`, optional 

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

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

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

97 the empty timespan. 

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

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

100 `TimespanDatabaseRepresentation` implementation only. If provided, 

101 all other arguments are are ignored. 

102 

103 Raises 

104 ------ 

105 TypeError 

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

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

108 ValueError 

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

110 supported by this class. 

111 

112 Notes 

113 ----- 

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

115 

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

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

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

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

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

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

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

123 ``overlaps``. 

124 

125 Finite timespan bounds are represented internally as integer nanoseconds, 

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

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

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

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

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

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

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

133 preserved even if the values are not. 

134 

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

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

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

138 """ 

139 

140 def __init__( 

141 self, 

142 begin: TimespanBound, 

143 end: TimespanBound, 

144 padInstantaneous: bool = True, 

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

146 ): 

147 converter = TimeConverter() 

148 if _nsec is None: 

149 begin_nsec: int 

150 if begin is None: 

151 begin_nsec = converter.min_nsec 

152 elif begin is self.EMPTY: 

153 begin_nsec = converter.max_nsec 

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

155 begin_nsec = converter.astropy_to_nsec(begin) 

156 else: 

157 raise TypeError( 

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

159 ) 

160 end_nsec: int 

161 if end is None: 

162 end_nsec = converter.max_nsec 

163 elif end is self.EMPTY: 

164 end_nsec = converter.min_nsec 

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

166 end_nsec = converter.astropy_to_nsec(end) 

167 else: 

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

169 if begin_nsec == end_nsec: 

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

171 with warnings.catch_warnings(): 

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

173 if erfa is not None: 

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

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

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

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

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

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

180 elif padInstantaneous: 

181 end_nsec += 1 

182 if end_nsec == converter.max_nsec: 

183 raise ValueError( 

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

185 "within one ns of maximum time." 

186 ) 

187 _nsec = (begin_nsec, end_nsec) 

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

189 # Standardizing all empty timespans to the same underlying values 

190 # here simplifies all other operations (including interactions 

191 # with TimespanDatabaseRepresentation implementations). 

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

193 self._nsec = _nsec 

194 

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

196 

197 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

198 

199 # YAML tag name for Timespan 

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

201 

202 @classmethod 

203 def makeEmpty(cls) -> Timespan: 

204 """Construct an empty timespan. 

205 

206 Returns 

207 ------- 

208 empty : `Timespan` 

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

210 and overlaps no other timespans (including itself). 

211 """ 

212 converter = TimeConverter() 

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

214 

215 @classmethod 

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

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

218 

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

220 timespan. 

221 

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

223 but may be slightly more efficient. 

224 

225 Parameters 

226 ---------- 

227 time : `astropy.time.Time` 

228 Time to use for the lower bound. 

229 

230 Returns 

231 ------- 

232 instant : `Timespan` 

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

234 """ 

235 converter = TimeConverter() 

236 nsec = converter.astropy_to_nsec(time) 

237 if nsec == converter.max_nsec - 1: 

238 raise ValueError( 

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

240 ) 

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

242 

243 @property 

244 @cached_getter 

245 def begin(self) -> TimespanBound: 

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

247 

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

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

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

251 """ 

252 if self.isEmpty(): 

253 return self.EMPTY 

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

255 return None 

256 else: 

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

258 

259 @property 

260 @cached_getter 

261 def end(self) -> TimespanBound: 

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

263 

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

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

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

267 """ 

268 if self.isEmpty(): 

269 return self.EMPTY 

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

271 return None 

272 else: 

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

274 

275 def isEmpty(self) -> bool: 

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

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

278 

279 def __str__(self) -> str: 

280 if self.isEmpty(): 

281 return "(empty)" 

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

283 # simulated data in the future 

284 with warnings.catch_warnings(): 

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

286 if erfa is not None: 

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

288 if self.begin is None: 

289 head = "(-∞, " 

290 else: 

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

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

293 if self.end is None: 

294 tail = "∞)" 

295 else: 

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

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

298 return head + tail 

299 

300 def __repr__(self) -> str: 

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

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

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

304 # eval-friendly __repr__. 

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

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

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

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

309 

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

311 if not isinstance(other, Timespan): 

312 return False 

313 # Correctness of this simple implementation depends on __init__ 

314 # standardizing all empty timespans to a single value. 

315 return self._nsec == other._nsec 

316 

317 def __hash__(self) -> int: 

318 # Correctness of this simple implementation depends on __init__ 

319 # standardizing all empty timespans to a single value. 

320 return hash(self._nsec) 

321 

322 def __reduce__(self) -> tuple: 

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

324 

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

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

327 

328 Parameters 

329 ---------- 

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

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

332 

333 Returns 

334 ------- 

335 less : `bool` 

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

337 empty. 

338 """ 

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

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

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

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

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

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

345 # first term is also false. 

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

347 nsec = TimeConverter().astropy_to_nsec(other) 

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

349 else: 

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

351 

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

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

354 

355 Parameters 

356 ---------- 

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

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

359 

360 Returns 

361 ------- 

362 greater : `bool` 

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

364 empty. 

365 """ 

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

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

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

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

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

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

372 # first term is also false. 

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

374 nsec = TimeConverter().astropy_to_nsec(other) 

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

376 else: 

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

378 

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

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

381 

382 Parameters 

383 ---------- 

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

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

386 a synonym for `contains`. 

387 

388 Returns 

389 ------- 

390 overlaps : `bool` 

391 The result of the overlap test. 

392 

393 Notes 

394 ----- 

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

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

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

398 """ 

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

400 return self.contains(other) 

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

402 

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

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

405 

406 Tests whether the intersection of this timespan with another timespan 

407 or point is equal to the other one. 

408 

409 Parameters 

410 ---------- 

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

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

413 

414 Returns 

415 ------- 

416 overlaps : `bool` 

417 The result of the contains test. 

418 

419 Notes 

420 ----- 

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

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

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

424 

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

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

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

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

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

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

431 """ 

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

433 nsec = TimeConverter().astropy_to_nsec(other) 

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

435 else: 

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

437 

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

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

440 

441 Parameters 

442 ---------- 

443 *args 

444 All positional arguments are `Timespan` instances. 

445 

446 Returns 

447 ------- 

448 intersection : `Timespan` 

449 The intersection timespan. 

450 """ 

451 if not args: 

452 return self 

453 lowers = [self._nsec[0]] 

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

455 uppers = [self._nsec[1]] 

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

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

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

459 

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

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

462 

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

464 

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

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

467 operands. 

468 

469 Parameters 

470 ---------- 

471 other : `Timespan` 

472 Timespan to subtract. 

473 

474 Yields 

475 ------ 

476 result : `Timespan` 

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

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

479 """ 

480 intersection = self.intersection(other) 

481 if intersection.isEmpty(): 

482 yield self 

483 elif intersection == self: 

484 yield from () 

485 else: 

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

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

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

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

490 

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

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

493 

494 Parameters 

495 ---------- 

496 minimal : `bool`, optional 

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

498 

499 Returns 

500 ------- 

501 simple : `list` of `int` 

502 The internal span as integer nanoseconds. 

503 """ 

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

505 return list(self._nsec) 

506 

507 @classmethod 

508 def from_simple( 

509 cls, 

510 simple: list[int], 

511 universe: DimensionUniverse | None = None, 

512 registry: Registry | None = None, 

513 ) -> Timespan: 

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

515 

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

517 

518 Parameters 

519 ---------- 

520 simple : `list` of `int` 

521 The values returned by `to_simple()`. 

522 universe : `DimensionUniverse`, optional 

523 Unused. 

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

525 Unused. 

526 

527 Returns 

528 ------- 

529 result : `Timespan` 

530 Newly-constructed object. 

531 """ 

532 nsec1, nsec2 = simple # for mypy 

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

534 

535 to_json = to_json_generic 

536 from_json: ClassVar = classmethod(from_json_generic) 

537 

538 @classmethod 

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

540 """Convert Timespan into YAML format. 

541 

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

543 value being a name of _SpecialTimespanBound enum. 

544 

545 Parameters 

546 ---------- 

547 dumper : `yaml.Dumper` 

548 YAML dumper instance. 

549 timespan : `Timespan` 

550 Data to be converted. 

551 """ 

552 if timespan.isEmpty(): 

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

554 else: 

555 return dumper.represent_mapping( 

556 cls.yaml_tag, 

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

558 ) 

559 

560 @classmethod 

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

562 """Convert YAML node into _SpecialTimespanBound. 

563 

564 Parameters 

565 ---------- 

566 loader : `yaml.SafeLoader` 

567 Instance of YAML loader class. 

568 node : `yaml.ScalarNode` 

569 YAML node. 

570 

571 Returns 

572 ------- 

573 value : `Timespan` 

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

575 """ 

576 if node.value is None: 

577 return None 

578 elif node.value == "EMPTY": 

579 return Timespan.makeEmpty() 

580 else: 

581 d = loader.construct_mapping(node) 

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

583 

584 

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

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

587 

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

589# only need SafeLoader. 

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

591 

592 

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

594 

595 

596class TimespanDatabaseRepresentation(ABC): 

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

598 

599 Notes 

600 ----- 

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

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

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

604 

605 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

608 """ 

609 

610 NAME: ClassVar[str] = "timespan" 

611 

612 Compound: ClassVar[type[TimespanDatabaseRepresentation]] 

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

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

615 endpoints. 

616 

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

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

619 """ 

620 

621 __slots__ = () 

622 

623 @classmethod 

624 @abstractmethod 

625 def makeFieldSpecs( 

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

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

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

629 

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

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

632 

633 Parameters 

634 ---------- 

635 nullable : `bool` 

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

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

638 the database are implementation-defined. Nullable timespan fields 

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

640 name : `str`, optional 

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

642 representations. Defaults to ``cls.NAME``. 

643 **kwargs 

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

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

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

647 

648 Returns 

649 ------- 

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

651 Field specification objects; length of the tuple is 

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

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

654 """ 

655 raise NotImplementedError() 

656 

657 @classmethod 

658 @abstractmethod 

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

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

661 

662 Parameters 

663 ---------- 

664 name : `str`, optional 

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

666 representations. Defaults to ``cls.NAME``. 

667 

668 Returns 

669 ------- 

670 names : `tuple` [ `str` ] 

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

672 specifications returned by `makeFieldSpecs`. 

673 """ 

674 raise NotImplementedError() 

675 

676 @classmethod 

677 @abstractmethod 

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

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

680 

681 Parameters 

682 ---------- 

683 timespan : `Timespan` or `None` 

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

685 timespan. 

686 

687 Returns 

688 ------- 

689 tsRepr : `TimespanDatabaseRepresentation` 

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

691 column expressions. 

692 """ 

693 raise NotImplementedError() 

694 

695 @classmethod 

696 @abstractmethod 

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

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

699 subquery. 

700 

701 Parameters 

702 ---------- 

703 columns : `sqlalchemy.sql.ColumnCollections` 

704 SQLAlchemy container for raw columns. 

705 name : `str`, optional 

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

707 representations. Defaults to ``cls.NAME``. 

708 

709 Returns 

710 ------- 

711 tsRepr : `TimespanDatabaseRepresentation` 

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

713 column expressions. 

714 """ 

715 raise NotImplementedError() 

716 

717 @classmethod 

718 @abstractmethod 

719 def update( 

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

721 ) -> dict[str, Any]: 

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

723 

724 Parameters 

725 ---------- 

726 timespan 

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

728 name : `str`, optional 

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

730 representations. Defaults to ``cls.NAME``. 

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

732 A dictionary representing a database row that fields should be 

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

734 

735 Returns 

736 ------- 

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

738 A dictionary containing this representation of a timespan. Exactly 

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

740 """ 

741 raise NotImplementedError() 

742 

743 @classmethod 

744 @abstractmethod 

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

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

747 

748 Parameters 

749 ---------- 

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

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

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

753 value of `getFieldNames`. 

754 name : `str`, optional 

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

756 representations. Defaults to ``cls.NAME``. 

757 

758 Returns 

759 ------- 

760 timespan : `Timespan` or `None` 

761 Python representation of the timespan. 

762 """ 

763 raise NotImplementedError() 

764 

765 @classmethod 

766 def hasExclusionConstraint(cls) -> bool: 

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

768 

769 Returns 

770 ------- 

771 supported : `bool` 

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

773 includes the fields of this representation is allowed. 

774 """ 

775 return False 

776 

777 @property 

778 @abstractmethod 

779 def name(self) -> str: 

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

781 (`str`). 

782 

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

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

785 common subset of the column names. 

786 """ 

787 raise NotImplementedError() 

788 

789 @abstractmethod 

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

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

792 

793 Returns a SQLAlchemy expression that tests whether this region is 

794 logically ``NULL``. 

795 

796 Returns 

797 ------- 

798 isnull : `sqlalchemy.sql.ColumnElement` 

799 A boolean SQLAlchemy expression object. 

800 """ 

801 raise NotImplementedError() 

802 

803 @abstractmethod 

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

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

806 

807 Parameters 

808 ---------- 

809 name : `str`, optional 

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

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

812 be used. 

813 

814 Returns 

815 ------- 

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

817 The true column or columns that back this object. 

818 """ 

819 raise NotImplementedError() 

820 

821 @abstractmethod 

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

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

824 

825 Returns 

826 ------- 

827 empty : `sqlalchemy.sql.ColumnElement` 

828 A boolean SQLAlchemy expression object. 

829 """ 

830 raise NotImplementedError() 

831 

832 @abstractmethod 

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

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

835 

836 Returns a SQLAlchemy expression representing a test for whether an 

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

838 point. 

839 

840 Parameters 

841 ---------- 

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

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

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

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

846 

847 Returns 

848 ------- 

849 less : `sqlalchemy.sql.ColumnElement` 

850 A boolean SQLAlchemy expression object. 

851 

852 Notes 

853 ----- 

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

855 """ 

856 raise NotImplementedError() 

857 

858 @abstractmethod 

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

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

861 

862 Returns a SQLAlchemy expression representing a test for whether an 

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

864 time point. 

865 

866 Parameters 

867 ---------- 

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

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

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

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

872 

873 Returns 

874 ------- 

875 greater : `sqlalchemy.sql.ColumnElement` 

876 A boolean SQLAlchemy expression object. 

877 

878 Notes 

879 ----- 

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

881 """ 

882 raise NotImplementedError() 

883 

884 @abstractmethod 

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

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

887 

888 Parameters 

889 ---------- 

890 other : ``type(self)`` 

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

892 this is a synonym for `contains`. 

893 

894 Returns 

895 ------- 

896 overlap : `sqlalchemy.sql.ColumnElement` 

897 A boolean SQLAlchemy expression object. 

898 

899 Notes 

900 ----- 

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

902 """ 

903 raise NotImplementedError() 

904 

905 @abstractmethod 

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

907 """Return a SQLAlchemy expression representing containment. 

908 

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

910 timespan or a time point. 

911 

912 Parameters 

913 ---------- 

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

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

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

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

918 

919 Returns 

920 ------- 

921 contains : `sqlalchemy.sql.ColumnElement` 

922 A boolean SQLAlchemy expression object. 

923 

924 Notes 

925 ----- 

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

927 """ 

928 raise NotImplementedError() 

929 

930 @abstractmethod 

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

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

933 timespan. 

934 

935 Returns 

936 ------- 

937 lower : `sqlalchemy.sql.ColumnElement` 

938 A SQLAlchemy expression for a lower bound. 

939 

940 Notes 

941 ----- 

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

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

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

945 predictable ordering. It may potentially be used for transforming 

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

947 attention to ordering issues. 

948 """ 

949 raise NotImplementedError() 

950 

951 @abstractmethod 

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

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

954 timespan. 

955 

956 Returns 

957 ------- 

958 upper : `sqlalchemy.sql.ColumnElement` 

959 A SQLAlchemy expression for an upper bound. 

960 

961 Notes 

962 ----- 

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

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

965 """ 

966 raise NotImplementedError() 

967 

968 

969class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

971 

972 An implementation of `TimespanDatabaseRepresentation` that simply stores 

973 the endpoints in two separate fields. 

974 

975 This type should generally be accessed via 

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

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

978 

979 Parameters 

980 ---------- 

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

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

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

984 nanoseconds. 

985 name : `str`, optional 

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

987 representations. Defaults to ``cls.NAME``. 

988 

989 Notes 

990 ----- 

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

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

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

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

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

996 by our integer-time mapping. 

997 """ 

998 

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

1000 self._nsec = nsec 

1001 self._name = name 

1002 

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

1004 

1005 @classmethod 

1006 def makeFieldSpecs( 

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

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

1009 # Docstring inherited. 

1010 if name is None: 

1011 name = cls.NAME 

1012 return ( 

1013 ddl.FieldSpec( 

1014 f"{name}_begin", 

1015 dtype=sqlalchemy.BigInteger, 

1016 nullable=nullable, 

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

1018 **kwargs, 

1019 ), 

1020 ddl.FieldSpec( 

1021 f"{name}_end", 

1022 dtype=sqlalchemy.BigInteger, 

1023 nullable=nullable, 

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

1025 **kwargs, 

1026 ), 

1027 ) 

1028 

1029 @classmethod 

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

1031 # Docstring inherited. 

1032 if name is None: 

1033 name = cls.NAME 

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

1035 

1036 @classmethod 

1037 def update( 

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

1039 ) -> dict[str, Any]: 

1040 # Docstring inherited. 

1041 if name is None: 

1042 name = cls.NAME 

1043 if result is None: 

1044 result = {} 

1045 if extent is None: 

1046 begin_nsec = None 

1047 end_nsec = None 

1048 else: 

1049 begin_nsec = extent._nsec[0] 

1050 end_nsec = extent._nsec[1] 

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

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

1053 return result 

1054 

1055 @classmethod 

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

1057 # Docstring inherited. 

1058 if name is None: 

1059 name = cls.NAME 

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

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

1062 if begin_nsec is None: 

1063 if end_nsec is not None: 

1064 raise RuntimeError( 

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

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

1067 ) 

1068 return None 

1069 elif end_nsec is None: 

1070 raise RuntimeError( 

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

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

1073 ) 

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

1075 

1076 @classmethod 

1077 def from_columns( 

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

1079 ) -> _CompoundTimespanDatabaseRepresentation: 

1080 # Docstring inherited. 

1081 if name is None: 

1082 name = cls.NAME 

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

1084 

1085 @classmethod 

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

1087 # Docstring inherited. 

1088 if timespan is None: 

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

1090 return cls( 

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

1092 name=cls.NAME, 

1093 ) 

1094 

1095 @property 

1096 def name(self) -> str: 

1097 # Docstring inherited. 

1098 return self._name 

1099 

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

1101 # Docstring inherited. 

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

1103 

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

1105 # Docstring inherited. 

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

1107 

1108 def __lt__( 

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

1110 ) -> sqlalchemy.sql.ColumnElement: 

1111 # Docstring inherited. 

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

1113 # expressions. 

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

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

1116 else: 

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

1118 

1119 def __gt__( 

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

1121 ) -> sqlalchemy.sql.ColumnElement: 

1122 # Docstring inherited. 

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

1124 # expressions. 

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

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

1127 else: 

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

1129 

1130 def overlaps( 

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

1132 ) -> sqlalchemy.sql.ColumnElement: 

1133 # Docstring inherited. 

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

1135 return self.contains(other) 

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

1137 

1138 def contains( 

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

1140 ) -> sqlalchemy.sql.ColumnElement: 

1141 # Docstring inherited. 

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

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

1144 else: 

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

1146 

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

1148 # Docstring inherited. 

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

1150 

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

1152 # Docstring inherited. 

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

1154 

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

1156 # Docstring inherited. 

1157 if name is None: 

1158 return self._nsec 

1159 else: 

1160 return ( 

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

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

1163 ) 

1164 

1165 

1166TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation