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 "DatabaseTimespanRepresentation" 

26) 

27 

28from abc import ABC, 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 

36from . import ddl 

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

38 

39 

40S = TypeVar("S", bound="DatabaseTimespanRepresentation") 

41 

42 

43class Timespan(NamedTuple): 

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

45 

46 Parameters 

47 ---------- 

48 begin : ``Timespan`` 

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

50 as -infinity. 

51 end : ``Timespan`` 

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

53 as +infinity. 

54 """ 

55 

56 begin: Optional[astropy.time.Time] 

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

58 

59 `None` should be interpreted as -infinity. 

60 """ 

61 

62 end: Optional[astropy.time.Time] 

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

64 

65 `None` should be interpreted as +infinity. 

66 """ 

67 

68 def __str__(self) -> str: 

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

70 # simulated data in the future 

71 with warnings.catch_warnings(): 

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

73 if self.begin is None: 

74 head = "(-∞, " 

75 else: 

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

77 if self.end is None: 

78 tail = "∞)" 

79 else: 

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

81 return head + tail 

82 

83 def __repr__(self) -> str: 

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

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

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

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

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

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

90 

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

92 # Include some fuzziness in equality because round tripping 

93 # can introduce some drift at the picosecond level 

94 # Butler is okay wih nanosecond precision 

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

96 return False 

97 

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

99 if t1 is None and t2 is None: 

100 return True 

101 if t1 is None or t2 is None: 

102 return False 

103 

104 return times_equal(t1, t2) 

105 

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

107 return result 

108 

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

110 # Need to override the explicit parent class implementation 

111 return not self.__eq__(other) 

112 

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

114 """Test whether this timespan overlaps another. 

115 

116 Parameters 

117 ---------- 

118 other : `Timespan` 

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

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

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

122 

123 Returns 

124 ------- 

125 overlaps : `Any` 

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

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

128 column expression. 

129 """ 

130 return ( 

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

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

133 ) 

134 

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

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

137 

138 Parameters 

139 ---------- 

140 *args 

141 All positional arguments are `Timespan` instances. 

142 

143 Returns 

144 ------- 

145 intersection : `Timespan` or `None` 

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

147 or no arguments. 

148 """ 

149 if len(args) == 0: 

150 return None 

151 elif len(args) == 1: 

152 return args[0] 

153 else: 

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

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

156 if not begins: 

157 begin = None 

158 elif len(begins) == 1: 

159 begin = begins[0] 

160 else: 

161 begin = max(*begins) 

162 if not ends: 

163 end = None 

164 elif len(ends) == 1: 

165 end = ends[0] 

166 else: 

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

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

169 return None 

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

171 

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

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

174 in ``self`` but not ``other``. 

175 

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

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

178 operands. 

179 

180 Parameters 

181 ---------- 

182 other : `Timespan` 

183 Timespan to subtract. 

184 

185 Yields 

186 ------ 

187 result : `Timespan` 

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

189 ``other``. 

190 """ 

191 if other.begin is not None: 

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

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

194 yield self 

195 else: 

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

197 if other.end is not None: 

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

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

200 yield self 

201 else: 

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

203 

204 

205class DatabaseTimespanRepresentation(ABC): 

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

207 database engine. 

208 

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

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

211 timespan overlap operations in query JOIN or WHERE clauses. 

212 """ 

213 

214 NAME: ClassVar[str] = "timespan" 

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

216 

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

218 """ 

219 

220 Compound: ClassVar[Type[DatabaseTimespanRepresentation]] 

221 """A concrete subclass of `DatabaseTimespanRepresentation` that simply 

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

223 endpoints. 

224 

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

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

227 """ 

228 

229 __slots__ = () 

230 

231 @classmethod 

232 @abstractmethod 

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

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

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

236 

237 Parameters 

238 ---------- 

239 nullable : `bool` 

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

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

242 the database are implementation-defined. Nullable timespan fields 

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

244 **kwargs 

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

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

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

248 

249 Returns 

250 ------- 

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

252 Field specification objects; length of the tuple is 

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

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

255 """ 

256 raise NotImplementedError() 

257 

258 @classmethod 

259 @abstractmethod 

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

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

262 

263 Returns 

264 ------- 

265 names : `tuple` [ `str` ] 

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

267 specifications returned by `makeFieldSpecs`. 

268 """ 

269 raise NotImplementedError() 

270 

271 @classmethod 

272 @abstractmethod 

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

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

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

276 in this representation. 

277 

278 Parameters 

279 ---------- 

280 timespan : `Timespan`, optional 

281 A concrete timespan. 

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

283 A dictionary representing a database row that fields should be 

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

285 

286 Returns 

287 ------- 

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

289 A dictionary containing this representation of a timespan. Exactly 

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

291 """ 

292 raise NotImplementedError() 

293 

294 @classmethod 

295 @abstractmethod 

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

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

298 database row in this representation. 

299 

300 Parameters 

301 ---------- 

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

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

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

305 value of `getFieldNames`. 

306 

307 Returns 

308 ------- 

309 timespan : `Timespan` or `None` 

310 Python representation of the timespan. 

311 """ 

312 raise NotImplementedError() 

313 

314 @classmethod 

315 def hasExclusionConstraint(cls) -> bool: 

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

317 

318 Returns 

319 ------- 

320 supported : `bool` 

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

322 includes the fields of this representation is allowed. 

323 """ 

324 return False 

325 

326 @classmethod 

327 @abstractmethod 

328 def fromSelectable(cls: Type[S], selectable: sqlalchemy.sql.FromClause) -> S: 

329 """Construct an instance of this class that proxies the columns of 

330 this representation in a table or SELECT query. 

331 

332 Parameters 

333 ---------- 

334 selectable : `sqlalchemy.sql.FromClause` 

335 SQLAlchemy object representing a table or SELECT query that has 

336 columns in this representation. 

337 

338 Returns 

339 ------- 

340 instance : `DatabaseTimespanRepresentation` 

341 An instance of this representation subclass. 

342 """ 

343 raise NotImplementedError() 

344 

345 @abstractmethod 

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

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

348 logically ``NULL``. 

349 

350 Returns 

351 ------- 

352 isnull : `sqlalchemy.sql.ColumnElement` 

353 A boolean SQLAlchemy expression object. 

354 """ 

355 raise NotImplementedError() 

356 

357 @abstractmethod 

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

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

360 timespans. 

361 

362 Parameters 

363 ---------- 

364 other : `Timespan` or `DatabaseTimespanRepresentation` 

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

366 literal or an instance of the same `DatabaseTimespanRepresentation` 

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

368 within the same database. 

369 

370 Returns 

371 ------- 

372 overlap : `sqlalchemy.sql.ColumnElement` 

373 A boolean SQLAlchemy expression object. 

374 """ 

375 raise NotImplementedError() 

376 

377 

378class _CompoundDatabaseTimespanRepresentation(DatabaseTimespanRepresentation): 

379 """An implementation of `DatabaseTimespanRepresentation` that simply stores 

380 the endpoints in two separate fields. 

381 

382 This type should generally be accessed via 

383 `DatabaseTimespanRepresentation.Compound`, and should be constructed only 

384 via the `fromSelectable` method. 

385 

386 Parameters 

387 ---------- 

388 begin : `sqlalchemy.sql.ColumnElement` 

389 SQLAlchemy object representing the begin (inclusive) endpoint. 

390 end : `sqlalchemy.sql.ColumnElement` 

391 SQLAlchemy object representing the end (exclusive) endpoint. 

392 

393 Notes 

394 ----- 

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

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

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

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

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

400 by our integer-time mapping. 

401 """ 

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

403 self.begin = begin 

404 self.end = end 

405 

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

407 

408 @classmethod 

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

410 # Docstring inherited. 

411 return ( 

412 ddl.FieldSpec( 

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

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

415 **kwargs, 

416 ), 

417 ddl.FieldSpec( 

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

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

420 **kwargs, 

421 ), 

422 ) 

423 

424 @classmethod 

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

426 # Docstring inherited. 

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

428 

429 @classmethod 

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

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

432 # Docstring inherited. 

433 if result is None: 

434 result = {} 

435 if timespan is None: 

436 begin = None 

437 end = None 

438 else: 

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

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

441 with warnings.catch_warnings(): 

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

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

444 begin = EPOCH 

445 else: 

446 begin = timespan.begin 

447 # MAX_TIME is first in comparison to force a conversion 

448 # from the supplied time scale to TAI rather than 

449 # forcing MAX_TIME to be continually converted to 

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

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

452 end = MAX_TIME 

453 else: 

454 end = timespan.end 

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

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

457 return result 

458 

459 @classmethod 

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

461 # Docstring inherited. 

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

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

464 if begin is None: 

465 if end is not None: 

466 raise RuntimeError( 

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

468 ) 

469 return None 

470 elif end is None: 

471 raise RuntimeError( 

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

473 ) 

474 if times_equal(begin, EPOCH): 

475 begin = None 

476 elif begin < EPOCH: 

477 raise RuntimeError( 

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

479 ) 

480 if times_equal(end, MAX_TIME): 

481 end = None 

482 elif end > MAX_TIME: 

483 raise RuntimeError( 

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

485 ) 

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

487 

488 @classmethod 

489 def fromSelectable(cls, selectable: sqlalchemy.sql.FromClause) -> _CompoundDatabaseTimespanRepresentation: 

490 # Docstring inherited. 

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

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

493 

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

495 # Docstring inherited. 

496 return self.begin.is_(None) 

497 

498 def overlaps(self, other: Union[Timespan, _CompoundDatabaseTimespanRepresentation] 

499 ) -> sqlalchemy.sql.ColumnElement: 

500 # Docstring inherited. 

501 if isinstance(other, Timespan): 

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

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

504 elif isinstance(other, _CompoundDatabaseTimespanRepresentation): 

505 begin = other.begin 

506 end = other.end 

507 else: 

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

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

510 

511 

512DatabaseTimespanRepresentation.Compound = _CompoundDatabaseTimespanRepresentation