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 

36from . import ddl 

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

38from ._topology import TopologicalExtentDatabaseRepresentation 

39 

40 

41class Timespan(NamedTuple): 

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

43 

44 Parameters 

45 ---------- 

46 begin : ``Timespan`` 

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

48 as -infinity. 

49 end : ``Timespan`` 

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

51 as +infinity. 

52 """ 

53 

54 begin: Optional[astropy.time.Time] 

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

56 

57 `None` should be interpreted as -infinity. 

58 """ 

59 

60 end: Optional[astropy.time.Time] 

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

62 

63 `None` should be interpreted as +infinity. 

64 """ 

65 

66 def __str__(self) -> str: 

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

68 # simulated data in the future 

69 with warnings.catch_warnings(): 

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

71 if self.begin is None: 

72 head = "(-∞, " 

73 else: 

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

75 if self.end is None: 

76 tail = "∞)" 

77 else: 

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

79 return head + tail 

80 

81 def __repr__(self) -> str: 

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

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

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

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

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

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

88 

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

90 # Include some fuzziness in equality because round tripping 

91 # can introduce some drift at the picosecond level 

92 # Butler is okay wih nanosecond precision 

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

94 return False 

95 

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

97 if t1 is None and t2 is None: 

98 return True 

99 if t1 is None or t2 is None: 

100 return False 

101 

102 return times_equal(t1, t2) 

103 

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

105 return result 

106 

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

108 # Need to override the explicit parent class implementation 

109 return not self.__eq__(other) 

110 

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

112 """Test whether this timespan overlaps another. 

113 

114 Parameters 

115 ---------- 

116 other : `Timespan` 

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

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

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

120 

121 Returns 

122 ------- 

123 overlaps : `Any` 

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

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

126 column expression. 

127 """ 

128 return ( 

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

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

131 ) 

132 

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

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

135 

136 Parameters 

137 ---------- 

138 *args 

139 All positional arguments are `Timespan` instances. 

140 

141 Returns 

142 ------- 

143 intersection : `Timespan` or `None` 

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

145 or no arguments. 

146 """ 

147 if len(args) == 0: 

148 return None 

149 elif len(args) == 1: 

150 return args[0] 

151 else: 

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

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

154 if not begins: 

155 begin = None 

156 elif len(begins) == 1: 

157 begin = begins[0] 

158 else: 

159 begin = max(*begins) 

160 if not ends: 

161 end = None 

162 elif len(ends) == 1: 

163 end = ends[0] 

164 else: 

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

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

167 return None 

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

169 

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

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

172 in ``self`` but not ``other``. 

173 

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

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

176 operands. 

177 

178 Parameters 

179 ---------- 

180 other : `Timespan` 

181 Timespan to subtract. 

182 

183 Yields 

184 ------ 

185 result : `Timespan` 

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

187 ``other``. 

188 """ 

189 if other.begin is not None: 

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

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

192 yield self 

193 else: 

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

195 if other.end is not None: 

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

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

198 yield self 

199 else: 

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

201 

202 

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

204 

205 

206class TimespanDatabaseRepresentation(TopologicalExtentDatabaseRepresentation): 

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

208 database engine. 

209 

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

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

212 timespan overlap operations in query JOIN or WHERE clauses. 

213 """ 

214 

215 NAME: ClassVar[str] = "timespan" 

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

217 

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

219 """ 

220 

221 Compound: ClassVar[Type[TimespanDatabaseRepresentation]] 

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

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

224 endpoints. 

225 

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

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

228 """ 

229 

230 __slots__ = () 

231 

232 @classmethod 

233 @abstractmethod 

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

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

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

237 

238 Parameters 

239 ---------- 

240 nullable : `bool` 

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

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

243 the database are implementation-defined. Nullable timespan fields 

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

245 **kwargs 

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

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

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

249 

250 Returns 

251 ------- 

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

253 Field specification objects; length of the tuple is 

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

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

256 """ 

257 raise NotImplementedError() 

258 

259 @classmethod 

260 @abstractmethod 

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

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

263 

264 Returns 

265 ------- 

266 names : `tuple` [ `str` ] 

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

268 specifications returned by `makeFieldSpecs`. 

269 """ 

270 raise NotImplementedError() 

271 

272 @classmethod 

273 @abstractmethod 

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

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

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

277 in this representation. 

278 

279 Parameters 

280 ---------- 

281 timespan : `Timespan`, optional 

282 A concrete timespan. 

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

284 A dictionary representing a database row that fields should be 

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

286 

287 Returns 

288 ------- 

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

290 A dictionary containing this representation of a timespan. Exactly 

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

292 """ 

293 raise NotImplementedError() 

294 

295 @classmethod 

296 @abstractmethod 

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

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

299 database row in this representation. 

300 

301 Parameters 

302 ---------- 

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

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

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

306 value of `getFieldNames`. 

307 

308 Returns 

309 ------- 

310 timespan : `Timespan` or `None` 

311 Python representation of the timespan. 

312 """ 

313 raise NotImplementedError() 

314 

315 @classmethod 

316 def hasExclusionConstraint(cls) -> bool: 

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

318 

319 Returns 

320 ------- 

321 supported : `bool` 

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

323 includes the fields of this representation is allowed. 

324 """ 

325 return False 

326 

327 @abstractmethod 

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

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

330 logically ``NULL``. 

331 

332 Returns 

333 ------- 

334 isnull : `sqlalchemy.sql.ColumnElement` 

335 A boolean SQLAlchemy expression object. 

336 """ 

337 raise NotImplementedError() 

338 

339 @abstractmethod 

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

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

342 timespans. 

343 

344 Parameters 

345 ---------- 

346 other : `Timespan` or `TimespanDatabaseRepresentation` 

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

348 literal or an instance of the same `TimespanDatabaseRepresentation` 

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

350 within the same database. 

351 

352 Returns 

353 ------- 

354 overlap : `sqlalchemy.sql.ColumnElement` 

355 A boolean SQLAlchemy expression object. 

356 """ 

357 raise NotImplementedError() 

358 

359 

360class _CompoundTimespanDatabaseRepresentation(TimespanDatabaseRepresentation): 

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

362 the endpoints in two separate fields. 

363 

364 This type should generally be accessed via 

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

366 via the `fromSelectable` method. 

367 

368 Parameters 

369 ---------- 

370 begin : `sqlalchemy.sql.ColumnElement` 

371 SQLAlchemy object representing the begin (inclusive) endpoint. 

372 end : `sqlalchemy.sql.ColumnElement` 

373 SQLAlchemy object representing the end (exclusive) endpoint. 

374 

375 Notes 

376 ----- 

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

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

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

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

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

382 by our integer-time mapping. 

383 """ 

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

385 self.begin = begin 

386 self.end = end 

387 

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

389 

390 @classmethod 

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

392 # Docstring inherited. 

393 return ( 

394 ddl.FieldSpec( 

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

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

397 **kwargs, 

398 ), 

399 ddl.FieldSpec( 

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

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

402 **kwargs, 

403 ), 

404 ) 

405 

406 @classmethod 

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

408 # Docstring inherited. 

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

410 

411 @classmethod 

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

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

414 # Docstring inherited. 

415 if result is None: 

416 result = {} 

417 if timespan is None: 

418 begin = None 

419 end = None 

420 else: 

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

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

423 with warnings.catch_warnings(): 

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

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

426 begin = EPOCH 

427 else: 

428 begin = timespan.begin 

429 # MAX_TIME is first in comparison to force a conversion 

430 # from the supplied time scale to TAI rather than 

431 # forcing MAX_TIME to be continually converted to 

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

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

434 end = MAX_TIME 

435 else: 

436 end = timespan.end 

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

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

439 return result 

440 

441 @classmethod 

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

443 # Docstring inherited. 

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

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

446 if begin is None: 

447 if end is not None: 

448 raise RuntimeError( 

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

450 ) 

451 return None 

452 elif end is None: 

453 raise RuntimeError( 

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

455 ) 

456 if times_equal(begin, EPOCH): 

457 begin = None 

458 elif begin < EPOCH: 

459 raise RuntimeError( 

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

461 ) 

462 if times_equal(end, MAX_TIME): 

463 end = None 

464 elif end > MAX_TIME: 

465 raise RuntimeError( 

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

467 ) 

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

469 

470 @classmethod 

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

472 # Docstring inherited. 

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

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

475 

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

477 # Docstring inherited. 

478 return self.begin.is_(None) 

479 

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

481 ) -> sqlalchemy.sql.ColumnElement: 

482 # Docstring inherited. 

483 if isinstance(other, Timespan): 

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

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

486 elif isinstance(other, _CompoundTimespanDatabaseRepresentation): 

487 begin = other.begin 

488 end = other.end 

489 else: 

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

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

492 

493 

494TimespanDatabaseRepresentation.Compound = _CompoundTimespanDatabaseRepresentation