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 sqlalchemy 

33 

34from . import ddl 

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

36 

37 

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

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 if self.begin is None: 

68 head = "(-∞, " 

69 else: 

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

71 if self.end is None: 

72 tail = "∞)" 

73 else: 

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

75 return head + tail 

76 

77 def __repr__(self) -> str: 

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

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

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

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

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

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

84 

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

86 # Include some fuzziness in equality because round tripping 

87 # can introduce some drift at the picosecond level 

88 # Butler is okay wih nanosecond precision 

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

90 return False 

91 

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

93 if t1 is None and t2 is None: 

94 return True 

95 if t1 is None or t2 is None: 

96 return False 

97 

98 return times_equal(t1, t2) 

99 

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

101 return result 

102 

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

104 # Need to override the explicit parent class implementation 

105 return not self.__eq__(other) 

106 

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

108 """Test whether this timespan overlaps another. 

109 

110 Parameters 

111 ---------- 

112 other : `Timespan` 

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

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

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

116 

117 Returns 

118 ------- 

119 overlaps : `Any` 

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

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

122 column expression. 

123 """ 

124 return ( 

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

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

127 ) 

128 

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

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

131 

132 Parameters 

133 ---------- 

134 *args 

135 All positional arguments are `Timespan` instances. 

136 

137 Returns 

138 ------- 

139 intersection : `Timespan` or `None` 

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

141 or no arguments. 

142 """ 

143 if len(args) == 0: 

144 return None 

145 elif len(args) == 1: 

146 return args[0] 

147 else: 

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

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

150 if not begins: 

151 begin = None 

152 elif len(begins) == 1: 

153 begin = begins[0] 

154 else: 

155 begin = max(*begins) 

156 if not ends: 

157 end = None 

158 elif len(ends) == 1: 

159 end = ends[0] 

160 else: 

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

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

163 return None 

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

165 

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

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

168 in ``self`` but not ``other``. 

169 

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

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

172 operands. 

173 

174 Parameters 

175 ---------- 

176 other : `Timespan` 

177 Timespan to subtract. 

178 

179 Yields 

180 ------ 

181 result : `Timespan` 

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

183 ``other``. 

184 """ 

185 if other.begin is not None: 

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

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

188 yield self 

189 else: 

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

191 if other.end is not None: 

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

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

194 yield self 

195 else: 

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

197 

198 

199class DatabaseTimespanRepresentation(ABC): 

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

201 database engine. 

202 

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

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

205 timespan overlap operations in query JOIN or WHERE clauses. 

206 """ 

207 

208 NAME: ClassVar[str] = "timespan" 

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

210 

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

212 """ 

213 

214 Compound: ClassVar[Type[DatabaseTimespanRepresentation]] 

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

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

217 endpoints. 

218 

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

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

221 """ 

222 

223 __slots__ = () 

224 

225 @classmethod 

226 @abstractmethod 

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

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

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

230 

231 Parameters 

232 ---------- 

233 nullable : `bool` 

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

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

236 the database are implementation-defined. Nullable timespan fields 

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

238 **kwargs 

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

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

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

242 

243 Returns 

244 ------- 

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

246 Field specification objects; length of the tuple is 

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

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

249 """ 

250 raise NotImplementedError() 

251 

252 @classmethod 

253 @abstractmethod 

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

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

256 

257 Returns 

258 ------- 

259 names : `tuple` [ `str` ] 

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

261 specifications returned by `makeFieldSpecs`. 

262 """ 

263 raise NotImplementedError() 

264 

265 @classmethod 

266 @abstractmethod 

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

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

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

270 in this representation. 

271 

272 Parameters 

273 ---------- 

274 timespan : `Timespan`, optional 

275 A concrete timespan. 

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

277 A dictionary representing a database row that fields should be 

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

279 

280 Returns 

281 ------- 

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

283 A dictionary containing this representation of a timespan. Exactly 

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

285 """ 

286 raise NotImplementedError() 

287 

288 @classmethod 

289 @abstractmethod 

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

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

292 database row in this representation. 

293 

294 Parameters 

295 ---------- 

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

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

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

299 value of `getFieldNames`. 

300 

301 Returns 

302 ------- 

303 timespan : `Timespan` or `None` 

304 Python representation of the timespan. 

305 """ 

306 raise NotImplementedError() 

307 

308 @classmethod 

309 def hasExclusionConstraint(cls) -> bool: 

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

311 

312 Returns 

313 ------- 

314 supported : `bool` 

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

316 includes the fields of this representation is allowed. 

317 """ 

318 return False 

319 

320 @classmethod 

321 @abstractmethod 

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

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

324 this representation in a table or SELECT query. 

325 

326 Parameters 

327 ---------- 

328 selectable : `sqlalchemy.sql.FromClause` 

329 SQLAlchemy object representing a table or SELECT query that has 

330 columns in this representation. 

331 

332 Returns 

333 ------- 

334 instance : `DatabaseTimespanRepresentation` 

335 An instance of this representation subclass. 

336 """ 

337 raise NotImplementedError() 

338 

339 @abstractmethod 

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

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

342 logically ``NULL``. 

343 

344 Returns 

345 ------- 

346 isnull : `sqlalchemy.sql.ColumnElement` 

347 A boolean SQLAlchemy expression object. 

348 """ 

349 raise NotImplementedError() 

350 

351 @abstractmethod 

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

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

354 timespans. 

355 

356 Parameters 

357 ---------- 

358 other : `Timespan` or `DatabaseTimespanRepresentation` 

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

360 literal or an instance of the same `DatabaseTimespanRepresentation` 

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

362 within the same database. 

363 

364 Returns 

365 ------- 

366 overlap : `sqlalchemy.sql.ColumnElement` 

367 A boolean SQLAlchemy expression object. 

368 """ 

369 raise NotImplementedError() 

370 

371 

372class _CompoundDatabaseTimespanRepresentation(DatabaseTimespanRepresentation): 

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

374 the endpoints in two separate fields. 

375 

376 This type should generally be accessed via 

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

378 via the `fromSelectable` method. 

379 

380 Parameters 

381 ---------- 

382 begin : `sqlalchemy.sql.ColumnElement` 

383 SQLAlchemy object representing the begin (inclusive) endpoint. 

384 end : `sqlalchemy.sql.ColumnElement` 

385 SQLAlchemy object representing the end (exclusive) endpoint. 

386 

387 Notes 

388 ----- 

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

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

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

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

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

394 by our integer-time mapping. 

395 """ 

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

397 self.begin = begin 

398 self.end = end 

399 

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

401 

402 @classmethod 

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

404 # Docstring inherited. 

405 return ( 

406 ddl.FieldSpec( 

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

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

409 **kwargs, 

410 ), 

411 ddl.FieldSpec( 

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

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

414 **kwargs, 

415 ), 

416 ) 

417 

418 @classmethod 

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

420 # Docstring inherited. 

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

422 

423 @classmethod 

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

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

426 # Docstring inherited. 

427 if result is None: 

428 result = {} 

429 if timespan is None: 

430 begin = None 

431 end = None 

432 else: 

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

434 begin = EPOCH 

435 else: 

436 begin = timespan.begin 

437 if timespan.end is None or timespan.end > MAX_TIME: 

438 end = MAX_TIME 

439 else: 

440 end = timespan.end 

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

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

443 return result 

444 

445 @classmethod 

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

447 # Docstring inherited. 

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

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

450 if begin is None: 

451 if end is not None: 

452 raise RuntimeError( 

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

454 ) 

455 return None 

456 elif end is None: 

457 raise RuntimeError( 

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

459 ) 

460 if times_equal(begin, EPOCH): 

461 begin = None 

462 elif begin < EPOCH: 

463 raise RuntimeError( 

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

465 ) 

466 if times_equal(end, MAX_TIME): 

467 end = None 

468 elif end > MAX_TIME: 

469 raise RuntimeError( 

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

471 ) 

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

473 

474 @classmethod 

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

476 # Docstring inherited. 

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

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

479 

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

481 # Docstring inherited. 

482 return self.begin.is_(None) 

483 

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

485 ) -> sqlalchemy.sql.ColumnElement: 

486 # Docstring inherited. 

487 if isinstance(other, Timespan): 

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

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

490 elif isinstance(other, _CompoundDatabaseTimespanRepresentation): 

491 begin = other.begin 

492 end = other.end 

493 else: 

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

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

496 

497 

498DatabaseTimespanRepresentation.Compound = _CompoundDatabaseTimespanRepresentation