Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21from __future__ import annotations 

22 

23__all__ = ( 

24 "Timespan", 

25 "TimespanDatabaseRepresentation", 

26) 

27 

28from abc import abstractmethod 

29from typing import Any, ClassVar, Dict, Iterator, Mapping, NamedTuple, Optional, Tuple, Type, TypeVar, Union 

30 

31import astropy.time 

32import astropy.utils.exceptions 

33import sqlalchemy 

34import warnings 

35 

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

37# ErfaWarning is no longer an AstropyWarning 

38try: 

39 import erfa 

40except ImportError: 

41 erfa = None 

42 

43from . import ddl 

44from .time_utils import astropy_to_nsec, EPOCH, MAX_TIME, times_equal 

45from ._topology import TopologicalExtentDatabaseRepresentation 

46 

47 

48class Timespan(NamedTuple): 

49 """A 2-element named tuple for time intervals. 

50 

51 Parameters 

52 ---------- 

53 begin : ``Timespan`` 

54 Minimum timestamp in the interval (inclusive). `None` is interpreted 

55 as -infinity. 

56 end : ``Timespan`` 

57 Maximum timestamp in the interval (exclusive). `None` is interpreted 

58 as +infinity. 

59 """ 

60 

61 begin: Optional[astropy.time.Time] 

62 """Minimum timestamp in the interval (inclusive). 

63 

64 `None` should be interpreted as -infinity. 

65 """ 

66 

67 end: Optional[astropy.time.Time] 

68 """Maximum timestamp in the interval (exclusive). 

69 

70 `None` should be interpreted as +infinity. 

71 """ 

72 

73 def __str__(self) -> str: 

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

75 # simulated data in the future 

76 with warnings.catch_warnings(): 

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

78 if erfa is not None: 

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

80 if self.begin is None: 

81 head = "(-∞, " 

82 else: 

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

84 if self.end is None: 

85 tail = "∞)" 

86 else: 

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

88 return head + tail 

89 

90 def __repr__(self) -> str: 

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

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

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

94 begin = tmpl.format(t=self.begin) if self.begin is not None else None 

95 end = tmpl.format(t=self.end) if self.end is not None else None 

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

97 

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

99 # Include some fuzziness in equality because round tripping 

100 # can introduce some drift at the picosecond level 

101 # Butler is okay wih nanosecond precision 

102 if not isinstance(other, type(self)): 

103 return False 

104 

105 def compare_time(t1: Optional[astropy.time.Time], t2: Optional[astropy.time.Time]) -> bool: 

106 if t1 is None and t2 is None: 

107 return True 

108 if t1 is None or t2 is None: 

109 return False 

110 

111 return times_equal(t1, t2) 

112 

113 result = compare_time(self.begin, other.begin) and compare_time(self.end, other.end) 

114 return result 

115 

116 def __ne__(self, other: Any) -> bool: 

117 # Need to override the explicit parent class implementation 

118 return not self.__eq__(other) 

119 

120 def overlaps(self, other: Timespan) -> Any: 

121 """Test whether this timespan overlaps another. 

122 

123 Parameters 

124 ---------- 

125 other : `Timespan` 

126 Another timespan whose begin and end values can be compared with 

127 those of ``self`` with the ``>=`` operator, yielding values 

128 that can be passed to ``ops.or_`` and/or ``ops.and_``. 

129 

130 Returns 

131 ------- 

132 overlaps : `Any` 

133 The result of the overlap. When ``ops`` is `operator`, this will 

134 be a `bool`. If ``ops`` is `sqlachemy.sql`, it will be a boolean 

135 column expression. 

136 """ 

137 return ( 

138 (self.end is None or other.begin is None or self.end > other.begin) 

139 and (self.begin is None or other.end is None or other.end > self.begin) 

140 ) 

141 

142 def intersection(*args: Timespan) -> Optional[Timespan]: 

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

144 

145 Parameters 

146 ---------- 

147 *args 

148 All positional arguments are `Timespan` instances. 

149 

150 Returns 

151 ------- 

152 intersection : `Timespan` or `None` 

153 The intersection timespan, or `None`, if there is no intersection 

154 or no arguments. 

155 """ 

156 if len(args) == 0: 

157 return None 

158 elif len(args) == 1: 

159 return args[0] 

160 else: 

161 begins = [ts.begin for ts in args if ts.begin is not None] 

162 ends = [ts.end for ts in args if ts.end is not None] 

163 if not begins: 

164 begin = None 

165 elif len(begins) == 1: 

166 begin = begins[0] 

167 else: 

168 begin = max(*begins) 

169 if not ends: 

170 end = None 

171 elif len(ends) == 1: 

172 end = ends[0] 

173 else: 

174 end = min(*ends) if ends else None 

175 if begin is not None and end is not None and begin >= end: 

176 return None 

177 return Timespan(begin=begin, end=end) 

178 

179 def difference(self, other: Timespan) -> Iterator[Timespan]: 

180 """Return the one or two timespans that cover the interval(s) that are 

181 in ``self`` but not ``other``. 

182 

183 This is implemented as an iterator because the result may be zero, one, 

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

185 operands. 

186 

187 Parameters 

188 ---------- 

189 other : `Timespan` 

190 Timespan to subtract. 

191 

192 Yields 

193 ------ 

194 result : `Timespan` 

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

196 ``other``. 

197 """ 

198 if other.begin is not None: 

199 if self.begin is None or self.begin < other.begin: 

200 if self.end is not None and self.end < other.begin: 

201 yield self 

202 else: 

203 yield Timespan(begin=self.begin, end=other.begin) 

204 if other.end is not None: 

205 if self.end is None or self.end > other.end: 

206 if self.begin is not None and self.begin > other.end: 

207 yield self 

208 else: 

209 yield Timespan(begin=other.end, end=self.end) 

210 

211 

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

213 

214 

215class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation): 

216 """An interface that encapsulates how timespans are represented in a 

217 database engine. 

218 

219 Most of this class's interface is comprised of classmethods. Instances 

220 can be constructed via the `fromSelectable` method as a way to include 

221 timespan overlap operations in query JOIN or WHERE clauses. 

222 """ 

223 

224 NAME: ClassVar[str] = "timespan" 

225 """Base name for all timespan fields in the database (`str`). 

226 

227 Actual field names may be derived from this, rather than exactly this. 

228 """ 

229 

230 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

232 uses two separate fields for the begin (inclusive) and end (excusive) 

233 endpoints. 

234 

235 This implementation should be compatibly with any SQL database, and should 

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

237 """ 

238 

239 __slots__ = () 

240 

241 @classmethod 

242 @abstractmethod 

243 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]: 

244 """Make one or more `ddl.FieldSpec` objects that reflect the fields 

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

246 

247 Parameters 

248 ---------- 

249 nullable : `bool` 

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

251 (mapped to `None` in Python), though the correspoding value(s) in 

252 the database are implementation-defined. Nullable timespan fields 

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

254 **kwargs 

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

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

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

258 

259 Returns 

260 ------- 

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

262 Field specification objects; length of the tuple is 

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

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

265 """ 

266 raise NotImplementedError() 

267 

268 @classmethod 

269 @abstractmethod 

270 def getFieldNames(cls) -> Tuple[str, ...]: 

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

272 

273 Returns 

274 ------- 

275 names : `tuple` [ `str` ] 

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

277 specifications returned by `makeFieldSpecs`. 

278 """ 

279 raise NotImplementedError() 

280 

281 @classmethod 

282 @abstractmethod 

283 def update(cls, timespan: Optional[Timespan], *, 

284 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 

285 """Add a `Timespan` to a dictionary that represents a database row 

286 in this representation. 

287 

288 Parameters 

289 ---------- 

290 timespan : `Timespan`, optional 

291 A concrete timespan. 

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

293 A dictionary representing a database row that fields should be 

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

295 

296 Returns 

297 ------- 

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

299 A dictionary containing this representation of a timespan. Exactly 

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

301 """ 

302 raise NotImplementedError() 

303 

304 @classmethod 

305 @abstractmethod 

306 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]: 

307 """Extract a `Timespan` instance from a dictionary that represents a 

308 database row in this representation. 

309 

310 Parameters 

311 ---------- 

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

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

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

315 value of `getFieldNames`. 

316 

317 Returns 

318 ------- 

319 timespan : `Timespan` or `None` 

320 Python representation of the timespan. 

321 """ 

322 raise NotImplementedError() 

323 

324 @classmethod 

325 def hasExclusionConstraint(cls) -> bool: 

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

327 

328 Returns 

329 ------- 

330 supported : `bool` 

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

332 includes the fields of this representation is allowed. 

333 """ 

334 return False 

335 

336 @abstractmethod 

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

338 """Return a SQLAlchemy expression that tests whether this timespan is 

339 logically ``NULL``. 

340 

341 Returns 

342 ------- 

343 isnull : `sqlalchemy.sql.ColumnElement` 

344 A boolean SQLAlchemy expression object. 

345 """ 

346 raise NotImplementedError() 

347 

348 @abstractmethod 

349 def overlaps(self: _S, other: Union[Timespan, _S]) -> sqlalchemy.sql.ColumnElement: 

350 """Return a SQLAlchemy expression representing an overlap operation on 

351 timespans. 

352 

353 Parameters 

354 ---------- 

355 other : `Timespan` or `TimespanDatabaseRepresentation` 

356 The timespan to overlap ``self`` with; either a Python `Timespan` 

357 literal or an instance of the same `TimespanDatabaseRepresentation` 

358 as ``self``, representing a timespan in some other table or query 

359 within the same database. 

360 

361 Returns 

362 ------- 

363 overlap : `sqlalchemy.sql.ColumnElement` 

364 A boolean SQLAlchemy expression object. 

365 """ 

366 raise NotImplementedError() 

367 

368 

369class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

370 """An implementation of `TimespanDatabaseRepresentation` that simply stores 

371 the endpoints in two separate fields. 

372 

373 This type should generally be accessed via 

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

375 via the `fromSelectable` method. 

376 

377 Parameters 

378 ---------- 

379 begin : `sqlalchemy.sql.ColumnElement` 

380 SQLAlchemy object representing the begin (inclusive) endpoint. 

381 end : `sqlalchemy.sql.ColumnElement` 

382 SQLAlchemy object representing the end (exclusive) endpoint. 

383 

384 Notes 

385 ----- 

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

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

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

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

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

391 by our integer-time mapping. 

392 """ 

393 def __init__(self, begin: sqlalchemy.sql.ColumnElement, end: sqlalchemy.sql.ColumnElement): 

394 self.begin = begin 

395 self.end = end 

396 

397 __slots__ = ("begin", "end") 

398 

399 @classmethod 

400 def makeFieldSpecs(cls, nullable: bool, **kwargs: Any) -> Tuple[ddl.FieldSpec, ...]: 

401 # Docstring inherited. 

402 return ( 

403 ddl.FieldSpec( 

404 f"{cls.NAME}_begin", dtype=ddl.AstropyTimeNsecTai, nullable=nullable, 

405 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(EPOCH)))), 

406 **kwargs, 

407 ), 

408 ddl.FieldSpec( 

409 f"{cls.NAME}_end", dtype=ddl.AstropyTimeNsecTai, nullable=nullable, 

410 default=(None if nullable else sqlalchemy.sql.text(str(astropy_to_nsec(MAX_TIME)))), 

411 **kwargs, 

412 ), 

413 ) 

414 

415 @classmethod 

416 def getFieldNames(cls) -> Tuple[str, ...]: 

417 # Docstring inherited. 

418 return (f"{cls.NAME}_begin", f"{cls.NAME}_end") 

419 

420 @classmethod 

421 def update(cls, timespan: Optional[Timespan], *, 

422 result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 

423 # Docstring inherited. 

424 if result is None: 

425 result = {} 

426 if timespan is None: 

427 begin = None 

428 end = None 

429 else: 

430 # These comparisons can trigger UTC -> TAI conversions that 

431 # can result in warnings for simulated data from the future 

432 with warnings.catch_warnings(): 

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

434 if erfa is not None: 

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

436 if timespan.begin is None or timespan.begin < EPOCH: 

437 begin = EPOCH 

438 else: 

439 begin = timespan.begin 

440 # MAX_TIME is first in comparison to force a conversion 

441 # from the supplied time scale to TAI rather than 

442 # forcing MAX_TIME to be continually converted to 

443 # the target time scale (which triggers warnings to UTC) 

444 if timespan.end is None or MAX_TIME <= timespan.end: 

445 end = MAX_TIME 

446 else: 

447 end = timespan.end 

448 result[f"{cls.NAME}_begin"] = begin 

449 result[f"{cls.NAME}_end"] = end 

450 return result 

451 

452 @classmethod 

453 def extract(cls, mapping: Mapping[str, Any]) -> Optional[Timespan]: 

454 # Docstring inherited. 

455 begin = mapping[f"{cls.NAME}_begin"] 

456 end = mapping[f"{cls.NAME}_end"] 

457 if begin is None: 

458 if end is not None: 

459 raise RuntimeError( 

460 f"Corrupted timespan extracted: begin is NULL, but end is {end}." 

461 ) 

462 return None 

463 elif end is None: 

464 raise RuntimeError( 

465 f"Corrupted timespan extracted: end is NULL, but begin is {begin}." 

466 ) 

467 if times_equal(begin, EPOCH): 

468 begin = None 

469 elif begin < EPOCH: 

470 raise RuntimeError( 

471 f"Corrupted timespan extracted: begin ({begin}) is before {EPOCH}." 

472 ) 

473 if times_equal(end, MAX_TIME): 

474 end = None 

475 elif end > MAX_TIME: 

476 raise RuntimeError( 

477 f"Corrupted timespan extracted: end ({end}) is after {MAX_TIME}." 

478 ) 

479 return Timespan(begin=begin, end=end) 

480 

481 @classmethod 

482 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause) -> _CompoundTimespanDatabaseRepresentation: 

483 # Docstring inherited. 

484 return cls(begin=selectable.columns[f"{cls.NAME}_begin"], 

485 end=selectable.columns[f"{cls.NAME}_end"]) 

486 

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

488 # Docstring inherited. 

489 return self.begin.is_(None) 

490 

491 def overlaps(self, other: Union[Timespan, _CompoundTimespanDatabaseRepresentation] 

492 ) -> sqlalchemy.sql.ColumnElement: 

493 # Docstring inherited. 

494 if isinstance(other, Timespan): 

495 begin = EPOCH if other.begin is None else other.begin 

496 end = MAX_TIME if other.end is None else other.end 

497 elif isinstance(other, _CompoundTimespanDatabaseRepresentation): 

498 begin = other.begin 

499 end = other.end 

500 else: 

501 raise TypeError(f"Unexpected argument to overlaps: {other!r}.") 

502 return sqlalchemy.sql.and_(self.end > begin, end > self.begin) 

503 

504 

505TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation