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

317 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-06 10:53 +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 fmt = "%Y-%m-%dT%H:%M:%S" 

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

284 # simulated data in the future 

285 with warnings.catch_warnings(): 

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

287 if erfa is not None: 

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

289 if self.begin is None: 

290 head = "(-∞, " 

291 else: 

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

293 head = f"[{self.begin.tai.strftime(fmt)}, " 

294 if self.end is None: 

295 tail = "∞)" 

296 else: 

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

298 tail = f"{self.end.tai.strftime(fmt)})" 

299 return head + tail 

300 

301 def __repr__(self) -> str: 

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

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

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

305 # eval-friendly __repr__. 

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

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

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

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

310 

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

312 if not isinstance(other, Timespan): 

313 return False 

314 # Correctness of this simple implementation depends on __init__ 

315 # standardizing all empty timespans to a single value. 

316 return self._nsec == other._nsec 

317 

318 def __hash__(self) -> int: 

319 # Correctness of this simple implementation depends on __init__ 

320 # standardizing all empty timespans to a single value. 

321 return hash(self._nsec) 

322 

323 def __reduce__(self) -> tuple: 

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

325 

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

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

328 

329 Parameters 

330 ---------- 

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

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

333 

334 Returns 

335 ------- 

336 less : `bool` 

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

338 empty. 

339 """ 

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

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

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

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

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

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

346 # first term is also false. 

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

348 nsec = TimeConverter().astropy_to_nsec(other) 

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

350 else: 

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

352 

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

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

355 

356 Parameters 

357 ---------- 

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

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

360 

361 Returns 

362 ------- 

363 greater : `bool` 

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

365 empty. 

366 """ 

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

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

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

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

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

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

373 # first term is also false. 

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

375 nsec = TimeConverter().astropy_to_nsec(other) 

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

377 else: 

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

379 

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

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

382 

383 Parameters 

384 ---------- 

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

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

387 a synonym for `contains`. 

388 

389 Returns 

390 ------- 

391 overlaps : `bool` 

392 The result of the overlap test. 

393 

394 Notes 

395 ----- 

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

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

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

399 """ 

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

401 return self.contains(other) 

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

403 

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

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

406 

407 Tests whether the intersection of this timespan with another timespan 

408 or point is equal to the other one. 

409 

410 Parameters 

411 ---------- 

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

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

414 

415 Returns 

416 ------- 

417 overlaps : `bool` 

418 The result of the contains test. 

419 

420 Notes 

421 ----- 

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

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

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

425 

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

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

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

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

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

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

432 """ 

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

434 nsec = TimeConverter().astropy_to_nsec(other) 

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

436 else: 

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

438 

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

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

441 

442 Parameters 

443 ---------- 

444 *args 

445 All positional arguments are `Timespan` instances. 

446 

447 Returns 

448 ------- 

449 intersection : `Timespan` 

450 The intersection timespan. 

451 """ 

452 if not args: 

453 return self 

454 lowers = [self._nsec[0]] 

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

456 uppers = [self._nsec[1]] 

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

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

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

460 

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

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

463 

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

465 

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

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

468 operands. 

469 

470 Parameters 

471 ---------- 

472 other : `Timespan` 

473 Timespan to subtract. 

474 

475 Yields 

476 ------ 

477 result : `Timespan` 

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

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

480 """ 

481 intersection = self.intersection(other) 

482 if intersection.isEmpty(): 

483 yield self 

484 elif intersection == self: 

485 yield from () 

486 else: 

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

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

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

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

491 

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

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

494 

495 Parameters 

496 ---------- 

497 minimal : `bool`, optional 

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

499 

500 Returns 

501 ------- 

502 simple : `list` of `int` 

503 The internal span as integer nanoseconds. 

504 """ 

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

506 return list(self._nsec) 

507 

508 @classmethod 

509 def from_simple( 

510 cls, 

511 simple: list[int] | None, 

512 universe: DimensionUniverse | None = None, 

513 registry: Registry | None = None, 

514 ) -> Timespan | None: 

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

516 

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

518 

519 Parameters 

520 ---------- 

521 simple : `list` of `int`, or `None` 

522 The values returned by `to_simple()`. 

523 universe : `DimensionUniverse`, optional 

524 Unused. 

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

526 Unused. 

527 

528 Returns 

529 ------- 

530 result : `Timespan` or `None` 

531 Newly-constructed object. 

532 """ 

533 if simple is None: 

534 return None 

535 nsec1, nsec2 = simple # for mypy 

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

537 

538 to_json = to_json_generic 

539 from_json: ClassVar = classmethod(from_json_generic) 

540 

541 @classmethod 

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

543 """Convert Timespan into YAML format. 

544 

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

546 value being a name of _SpecialTimespanBound enum. 

547 

548 Parameters 

549 ---------- 

550 dumper : `yaml.Dumper` 

551 YAML dumper instance. 

552 timespan : `Timespan` 

553 Data to be converted. 

554 """ 

555 if timespan.isEmpty(): 

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

557 else: 

558 return dumper.represent_mapping( 

559 cls.yaml_tag, 

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

561 ) 

562 

563 @classmethod 

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

565 """Convert YAML node into _SpecialTimespanBound. 

566 

567 Parameters 

568 ---------- 

569 loader : `yaml.SafeLoader` 

570 Instance of YAML loader class. 

571 node : `yaml.ScalarNode` 

572 YAML node. 

573 

574 Returns 

575 ------- 

576 value : `Timespan` 

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

578 """ 

579 if node.value is None: 

580 return None 

581 elif node.value == "EMPTY": 

582 return Timespan.makeEmpty() 

583 else: 

584 d = loader.construct_mapping(node) 

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

586 

587 

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

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

590 

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

592# only need SafeLoader. 

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

594 

595 

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

597 

598 

599class TimespanDatabaseRepresentation(ABC): 

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

601 

602 Notes 

603 ----- 

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

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

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

607 

608 `TimespanDatabaseRepresentation` implementations are guaranteed to use the 

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

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

611 """ 

612 

613 NAME: ClassVar[str] = "timespan" 

614 

615 Compound: ClassVar[type[TimespanDatabaseRepresentation]] 

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

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

618 endpoints. 

619 

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

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

622 """ 

623 

624 __slots__ = () 

625 

626 @classmethod 

627 @abstractmethod 

628 def makeFieldSpecs( 

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

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

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

632 

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

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

635 

636 Parameters 

637 ---------- 

638 nullable : `bool` 

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

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

641 the database are implementation-defined. Nullable timespan fields 

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

643 name : `str`, optional 

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

645 representations. Defaults to ``cls.NAME``. 

646 **kwargs 

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

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

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

650 

651 Returns 

652 ------- 

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

654 Field specification objects; length of the tuple is 

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

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

657 """ 

658 raise NotImplementedError() 

659 

660 @classmethod 

661 @abstractmethod 

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

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

664 

665 Parameters 

666 ---------- 

667 name : `str`, optional 

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

669 representations. Defaults to ``cls.NAME``. 

670 

671 Returns 

672 ------- 

673 names : `tuple` [ `str` ] 

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

675 specifications returned by `makeFieldSpecs`. 

676 """ 

677 raise NotImplementedError() 

678 

679 @classmethod 

680 @abstractmethod 

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

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

683 

684 Parameters 

685 ---------- 

686 timespan : `Timespan` or `None` 

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

688 timespan. 

689 

690 Returns 

691 ------- 

692 tsRepr : `TimespanDatabaseRepresentation` 

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

694 column expressions. 

695 """ 

696 raise NotImplementedError() 

697 

698 @classmethod 

699 @abstractmethod 

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

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

702 subquery. 

703 

704 Parameters 

705 ---------- 

706 columns : `sqlalchemy.sql.ColumnCollections` 

707 SQLAlchemy container for raw columns. 

708 name : `str`, optional 

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

710 representations. Defaults to ``cls.NAME``. 

711 

712 Returns 

713 ------- 

714 tsRepr : `TimespanDatabaseRepresentation` 

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

716 column expressions. 

717 """ 

718 raise NotImplementedError() 

719 

720 @classmethod 

721 @abstractmethod 

722 def update( 

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

724 ) -> dict[str, Any]: 

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

726 

727 Parameters 

728 ---------- 

729 timespan 

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

731 name : `str`, optional 

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

733 representations. Defaults to ``cls.NAME``. 

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

735 A dictionary representing a database row that fields should be 

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

737 

738 Returns 

739 ------- 

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

741 A dictionary containing this representation of a timespan. Exactly 

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

743 """ 

744 raise NotImplementedError() 

745 

746 @classmethod 

747 @abstractmethod 

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

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

750 

751 Parameters 

752 ---------- 

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

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

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

756 value of `getFieldNames`. 

757 name : `str`, optional 

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

759 representations. Defaults to ``cls.NAME``. 

760 

761 Returns 

762 ------- 

763 timespan : `Timespan` or `None` 

764 Python representation of the timespan. 

765 """ 

766 raise NotImplementedError() 

767 

768 @classmethod 

769 def hasExclusionConstraint(cls) -> bool: 

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

771 

772 Returns 

773 ------- 

774 supported : `bool` 

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

776 includes the fields of this representation is allowed. 

777 """ 

778 return False 

779 

780 @property 

781 @abstractmethod 

782 def name(self) -> str: 

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

784 (`str`). 

785 

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

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

788 common subset of the column names. 

789 """ 

790 raise NotImplementedError() 

791 

792 @abstractmethod 

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

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

795 

796 Returns a SQLAlchemy expression that tests whether this region is 

797 logically ``NULL``. 

798 

799 Returns 

800 ------- 

801 isnull : `sqlalchemy.sql.ColumnElement` 

802 A boolean SQLAlchemy expression object. 

803 """ 

804 raise NotImplementedError() 

805 

806 @abstractmethod 

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

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

809 

810 Parameters 

811 ---------- 

812 name : `str`, optional 

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

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

815 be used. 

816 

817 Returns 

818 ------- 

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

820 The true column or columns that back this object. 

821 """ 

822 raise NotImplementedError() 

823 

824 @abstractmethod 

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

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

827 

828 Returns 

829 ------- 

830 empty : `sqlalchemy.sql.ColumnElement` 

831 A boolean SQLAlchemy expression object. 

832 """ 

833 raise NotImplementedError() 

834 

835 @abstractmethod 

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

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

838 

839 Returns a SQLAlchemy expression representing a test for whether an 

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

841 point. 

842 

843 Parameters 

844 ---------- 

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

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

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

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

849 

850 Returns 

851 ------- 

852 less : `sqlalchemy.sql.ColumnElement` 

853 A boolean SQLAlchemy expression object. 

854 

855 Notes 

856 ----- 

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

858 """ 

859 raise NotImplementedError() 

860 

861 @abstractmethod 

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

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

864 

865 Returns a SQLAlchemy expression representing a test for whether an 

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

867 time point. 

868 

869 Parameters 

870 ---------- 

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

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

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

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

875 

876 Returns 

877 ------- 

878 greater : `sqlalchemy.sql.ColumnElement` 

879 A boolean SQLAlchemy expression object. 

880 

881 Notes 

882 ----- 

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

884 """ 

885 raise NotImplementedError() 

886 

887 @abstractmethod 

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

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

890 

891 Parameters 

892 ---------- 

893 other : ``type(self)`` 

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

895 this is a synonym for `contains`. 

896 

897 Returns 

898 ------- 

899 overlap : `sqlalchemy.sql.ColumnElement` 

900 A boolean SQLAlchemy expression object. 

901 

902 Notes 

903 ----- 

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

905 """ 

906 raise NotImplementedError() 

907 

908 @abstractmethod 

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

910 """Return a SQLAlchemy expression representing containment. 

911 

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

913 timespan or a time point. 

914 

915 Parameters 

916 ---------- 

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

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

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

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

921 

922 Returns 

923 ------- 

924 contains : `sqlalchemy.sql.ColumnElement` 

925 A boolean SQLAlchemy expression object. 

926 

927 Notes 

928 ----- 

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

930 """ 

931 raise NotImplementedError() 

932 

933 @abstractmethod 

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

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

936 timespan. 

937 

938 Returns 

939 ------- 

940 lower : `sqlalchemy.sql.ColumnElement` 

941 A SQLAlchemy expression for a lower bound. 

942 

943 Notes 

944 ----- 

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

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

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

948 predictable ordering. It may potentially be used for transforming 

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

950 attention to ordering issues. 

951 """ 

952 raise NotImplementedError() 

953 

954 @abstractmethod 

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

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

957 timespan. 

958 

959 Returns 

960 ------- 

961 upper : `sqlalchemy.sql.ColumnElement` 

962 A SQLAlchemy expression for an upper bound. 

963 

964 Notes 

965 ----- 

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

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

968 """ 

969 raise NotImplementedError() 

970 

971 

972class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

974 

975 An implementation of `TimespanDatabaseRepresentation` that simply stores 

976 the endpoints in two separate fields. 

977 

978 This type should generally be accessed via 

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

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

981 

982 Parameters 

983 ---------- 

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

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

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

987 nanoseconds. 

988 name : `str`, optional 

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

990 representations. Defaults to ``cls.NAME``. 

991 

992 Notes 

993 ----- 

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

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

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

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

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

999 by our integer-time mapping. 

1000 """ 

1001 

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

1003 self._nsec = nsec 

1004 self._name = name 

1005 

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

1007 

1008 @classmethod 

1009 def makeFieldSpecs( 

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

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

1012 # Docstring inherited. 

1013 if name is None: 

1014 name = cls.NAME 

1015 return ( 

1016 ddl.FieldSpec( 

1017 f"{name}_begin", 

1018 dtype=sqlalchemy.BigInteger, 

1019 nullable=nullable, 

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

1021 **kwargs, 

1022 ), 

1023 ddl.FieldSpec( 

1024 f"{name}_end", 

1025 dtype=sqlalchemy.BigInteger, 

1026 nullable=nullable, 

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

1028 **kwargs, 

1029 ), 

1030 ) 

1031 

1032 @classmethod 

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

1034 # Docstring inherited. 

1035 if name is None: 

1036 name = cls.NAME 

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

1038 

1039 @classmethod 

1040 def update( 

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

1042 ) -> dict[str, Any]: 

1043 # Docstring inherited. 

1044 if name is None: 

1045 name = cls.NAME 

1046 if result is None: 

1047 result = {} 

1048 if extent is None: 

1049 begin_nsec = None 

1050 end_nsec = None 

1051 else: 

1052 begin_nsec = extent._nsec[0] 

1053 end_nsec = extent._nsec[1] 

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

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

1056 return result 

1057 

1058 @classmethod 

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

1060 # Docstring inherited. 

1061 if name is None: 

1062 name = cls.NAME 

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

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

1065 if begin_nsec is None: 

1066 if end_nsec is not None: 

1067 raise RuntimeError( 

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

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

1070 ) 

1071 return None 

1072 elif end_nsec is None: 

1073 raise RuntimeError( 

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

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

1076 ) 

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

1078 

1079 @classmethod 

1080 def from_columns( 

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

1082 ) -> _CompoundTimespanDatabaseRepresentation: 

1083 # Docstring inherited. 

1084 if name is None: 

1085 name = cls.NAME 

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

1087 

1088 @classmethod 

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

1090 # Docstring inherited. 

1091 if timespan is None: 

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

1093 return cls( 

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

1095 name=cls.NAME, 

1096 ) 

1097 

1098 @property 

1099 def name(self) -> str: 

1100 # Docstring inherited. 

1101 return self._name 

1102 

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

1104 # Docstring inherited. 

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

1106 

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

1108 # Docstring inherited. 

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

1110 

1111 def __lt__( 

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

1113 ) -> sqlalchemy.sql.ColumnElement: 

1114 # Docstring inherited. 

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

1116 # expressions. 

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

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

1119 else: 

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

1121 

1122 def __gt__( 

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

1124 ) -> sqlalchemy.sql.ColumnElement: 

1125 # Docstring inherited. 

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

1127 # expressions. 

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

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

1130 else: 

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

1132 

1133 def overlaps( 

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

1135 ) -> sqlalchemy.sql.ColumnElement: 

1136 # Docstring inherited. 

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

1138 return self.contains(other) 

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

1140 

1141 def contains( 

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

1143 ) -> sqlalchemy.sql.ColumnElement: 

1144 # Docstring inherited. 

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

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

1147 else: 

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

1149 

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

1151 # Docstring inherited. 

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

1153 

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

1155 # Docstring inherited. 

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

1157 

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

1159 # Docstring inherited. 

1160 if name is None: 

1161 return self._nsec 

1162 else: 

1163 return ( 

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

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

1166 ) 

1167 

1168 

1169TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation