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

182 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-13 10:57 +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__ = ("Timespan",) 

30 

31import enum 

32import warnings 

33from collections.abc import Generator 

34from typing import TYPE_CHECKING, Any, ClassVar, Union 

35 

36import astropy.time 

37import astropy.utils.exceptions 

38import yaml 

39 

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

41# ErfaWarning is no longer an AstropyWarning 

42try: 

43 import erfa 

44except ImportError: 

45 erfa = None 

46 

47from lsst.utils.classes import cached_getter 

48 

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 .dimensions import DimensionUniverse 

54 from .registry import Registry 

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 fmt = "%Y-%m-%dT%H:%M:%S" 

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

278 # simulated data in the future 

279 with warnings.catch_warnings(): 

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

281 if erfa is not None: 

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

283 if self.begin is None: 

284 head = "(-∞, " 

285 else: 

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

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

288 if self.end is None: 

289 tail = "∞)" 

290 else: 

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

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

293 return head + tail 

294 

295 def __repr__(self) -> str: 

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

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

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

299 # eval-friendly __repr__. 

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

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

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

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

304 

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

306 if not isinstance(other, Timespan): 

307 return False 

308 # Correctness of this simple implementation depends on __init__ 

309 # standardizing all empty timespans to a single value. 

310 return self._nsec == other._nsec 

311 

312 def __hash__(self) -> int: 

313 # Correctness of this simple implementation depends on __init__ 

314 # standardizing all empty timespans to a single value. 

315 return hash(self._nsec) 

316 

317 def __reduce__(self) -> tuple: 

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

319 

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

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

322 

323 Parameters 

324 ---------- 

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

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

327 

328 Returns 

329 ------- 

330 less : `bool` 

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

332 empty. 

333 """ 

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

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

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

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

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

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

340 # first term is also false. 

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

342 nsec = TimeConverter().astropy_to_nsec(other) 

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

344 else: 

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

346 

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

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

349 

350 Parameters 

351 ---------- 

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

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

354 

355 Returns 

356 ------- 

357 greater : `bool` 

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

359 empty. 

360 """ 

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

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

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

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

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

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

367 # first term is also false. 

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

369 nsec = TimeConverter().astropy_to_nsec(other) 

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

371 else: 

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

373 

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

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

376 

377 Parameters 

378 ---------- 

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

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

381 a synonym for `contains`. 

382 

383 Returns 

384 ------- 

385 overlaps : `bool` 

386 The result of the overlap test. 

387 

388 Notes 

389 ----- 

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

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

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

393 """ 

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

395 return self.contains(other) 

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

397 

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

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

400 

401 Tests whether the intersection of this timespan with another timespan 

402 or point is equal to the other one. 

403 

404 Parameters 

405 ---------- 

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

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

408 

409 Returns 

410 ------- 

411 overlaps : `bool` 

412 The result of the contains test. 

413 

414 Notes 

415 ----- 

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

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

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

419 

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

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

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

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

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

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

426 """ 

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

428 nsec = TimeConverter().astropy_to_nsec(other) 

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

430 else: 

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

432 

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

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

435 

436 Parameters 

437 ---------- 

438 *args 

439 All positional arguments are `Timespan` instances. 

440 

441 Returns 

442 ------- 

443 intersection : `Timespan` 

444 The intersection timespan. 

445 """ 

446 if not args: 

447 return self 

448 lowers = [self._nsec[0]] 

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

450 uppers = [self._nsec[1]] 

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

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

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

454 

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

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

457 

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

459 

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

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

462 operands. 

463 

464 Parameters 

465 ---------- 

466 other : `Timespan` 

467 Timespan to subtract. 

468 

469 Yields 

470 ------ 

471 result : `Timespan` 

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

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

474 """ 

475 intersection = self.intersection(other) 

476 if intersection.isEmpty(): 

477 yield self 

478 elif intersection == self: 

479 yield from () 

480 else: 

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

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

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

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

485 

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

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

488 

489 Parameters 

490 ---------- 

491 minimal : `bool`, optional 

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

493 

494 Returns 

495 ------- 

496 simple : `list` of `int` 

497 The internal span as integer nanoseconds. 

498 """ 

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

500 return list(self._nsec) 

501 

502 @classmethod 

503 def from_simple( 

504 cls, 

505 simple: list[int] | None, 

506 universe: DimensionUniverse | None = None, 

507 registry: Registry | None = None, 

508 ) -> Timespan | None: 

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

510 

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

512 

513 Parameters 

514 ---------- 

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

516 The values returned by `to_simple()`. 

517 universe : `DimensionUniverse`, optional 

518 Unused. 

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

520 Unused. 

521 

522 Returns 

523 ------- 

524 result : `Timespan` or `None` 

525 Newly-constructed object. 

526 """ 

527 if simple is None: 

528 return None 

529 nsec1, nsec2 = simple # for mypy 

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

531 

532 to_json = to_json_generic 

533 from_json: ClassVar = classmethod(from_json_generic) 

534 

535 @classmethod 

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

537 """Convert Timespan into YAML format. 

538 

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

540 value being a name of _SpecialTimespanBound enum. 

541 

542 Parameters 

543 ---------- 

544 dumper : `yaml.Dumper` 

545 YAML dumper instance. 

546 timespan : `Timespan` 

547 Data to be converted. 

548 """ 

549 if timespan.isEmpty(): 

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

551 else: 

552 return dumper.represent_mapping( 

553 cls.yaml_tag, 

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

555 ) 

556 

557 @classmethod 

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

559 """Convert YAML node into _SpecialTimespanBound. 

560 

561 Parameters 

562 ---------- 

563 loader : `yaml.SafeLoader` 

564 Instance of YAML loader class. 

565 node : `yaml.ScalarNode` 

566 YAML node. 

567 

568 Returns 

569 ------- 

570 value : `Timespan` 

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

572 """ 

573 if node.value is None: 

574 return None 

575 elif node.value == "EMPTY": 

576 return Timespan.makeEmpty() 

577 else: 

578 d = loader.construct_mapping(node) 

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

580 

581 

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

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

584 

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

586# only need SafeLoader. 

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