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}, " 

71 if self.end is None: 

72 tail = "∞)" 

73 else: 

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

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 overlaps(self, other: Timespan) -> Any: 

86 """Test whether this timespan overlaps another. 

87 

88 Parameters 

89 ---------- 

90 other : `Timespan` 

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

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

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

94 

95 Returns 

96 ------- 

97 overlaps : `Any` 

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

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

100 column expression. 

101 """ 

102 return ( 

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

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

105 ) 

106 

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

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

109 

110 Parameters 

111 ---------- 

112 *args 

113 All positional arguments are `Timespan` instances. 

114 

115 Returns 

116 ------- 

117 intersection : `Timespan` or `None` 

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

119 or no arguments. 

120 """ 

121 if len(args) == 0: 

122 return None 

123 elif len(args) == 1: 

124 return args[0] 

125 else: 

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

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

128 if not begins: 

129 begin = None 

130 elif len(begins) == 1: 

131 begin = begins[0] 

132 else: 

133 begin = max(*begins) 

134 if not ends: 

135 end = None 

136 elif len(ends) == 1: 

137 end = ends[0] 

138 else: 

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

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

141 return None 

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

143 

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

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

146 in ``self`` but not ``other``. 

147 

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

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

150 operands. 

151 

152 Parameters 

153 ---------- 

154 other : `Timespan` 

155 Timespan to subtract. 

156 

157 Yields 

158 ------ 

159 result : `Timespan` 

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

161 ``other``. 

162 """ 

163 if other.begin is not None: 

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

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

166 yield self 

167 else: 

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

169 if other.end is not None: 

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

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

172 yield self 

173 else: 

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

175 

176 

177class DatabaseTimespanRepresentation(ABC): 

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

179 database engine. 

180 

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

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

183 timespan overlap operations in query JOIN or WHERE clauses. 

184 """ 

185 

186 NAME: ClassVar[str] = "timespan" 

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

188 

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

190 """ 

191 

192 Compound: ClassVar[Type[DatabaseTimespanRepresentation]] 

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

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

195 endpoints. 

196 

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

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

199 """ 

200 

201 __slots__ = () 

202 

203 @classmethod 

204 @abstractmethod 

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

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

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

208 

209 Parameters 

210 ---------- 

211 nullable : `bool` 

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

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

214 the database are implementation-defined. Nullable timespan fields 

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

216 **kwargs 

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

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

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

220 

221 Returns 

222 ------- 

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

224 Field specification objects; length of the tuple is 

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

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

227 """ 

228 raise NotImplementedError() 

229 

230 @classmethod 

231 @abstractmethod 

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

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

234 

235 Returns 

236 ------- 

237 names : `tuple` [ `str` ] 

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

239 specifications returned by `makeFieldSpecs`. 

240 """ 

241 raise NotImplementedError() 

242 

243 @classmethod 

244 @abstractmethod 

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

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

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

248 in this representation. 

249 

250 Parameters 

251 ---------- 

252 timespan : `Timespan`, optional 

253 A concrete timespan. 

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

255 A dictionary representing a database row that fields should be 

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

257 

258 Returns 

259 ------- 

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

261 A dictionary containing this representation of a timespan. Exactly 

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

263 """ 

264 raise NotImplementedError() 

265 

266 @classmethod 

267 @abstractmethod 

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

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

270 database row in this representation. 

271 

272 Parameters 

273 ---------- 

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

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

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

277 value of `getFieldNames`. 

278 

279 Returns 

280 ------- 

281 timespan : `Timespan` or `None` 

282 Python representation of the timespan. 

283 """ 

284 raise NotImplementedError() 

285 

286 @classmethod 

287 def hasExclusionConstraint(cls) -> bool: 

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

289 

290 Returns 

291 ------- 

292 supported : `bool` 

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

294 includes the fields of this representation is allowed. 

295 """ 

296 return False 

297 

298 @classmethod 

299 @abstractmethod 

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

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

302 this representation in a table or SELECT query. 

303 

304 Parameters 

305 ---------- 

306 selectable : `sqlalchemy.sql.FromClause` 

307 SQLAlchemy object representing a table or SELECT query that has 

308 columns in this representation. 

309 

310 Returns 

311 ------- 

312 instance : `DatabaseTimespanRepresentation` 

313 An instance of this representation subclass. 

314 """ 

315 raise NotImplementedError() 

316 

317 @abstractmethod 

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

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

320 logically ``NULL``. 

321 

322 Returns 

323 ------- 

324 isnull : `sqlalchemy.sql.ColumnElement` 

325 A boolean SQLAlchemy expression object. 

326 """ 

327 raise NotImplementedError() 

328 

329 @abstractmethod 

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

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

332 timespans. 

333 

334 Parameters 

335 ---------- 

336 other : `Timespan` or `DatabaseTimespanRepresentation` 

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

338 literal or an instance of the same `DatabaseTimespanRepresentation` 

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

340 within the same database. 

341 

342 Returns 

343 ------- 

344 overlap : `sqlalchemy.sql.ColumnElement` 

345 A boolean SQLAlchemy expression object. 

346 """ 

347 raise NotImplementedError() 

348 

349 

350class _CompoundDatabaseTimespanRepresentation(DatabaseTimespanRepresentation): 

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

352 the endpoints in two separate fields. 

353 

354 This type should generally be accessed via 

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

356 via the `fromSelectable` method. 

357 

358 Parameters 

359 ---------- 

360 begin : `sqlalchemy.sql.ColumnElement` 

361 SQLAlchemy object representing the begin (inclusive) endpoint. 

362 end : `sqlalchemy.sql.ColumnElement` 

363 SQLAlchemy object representing the end (exclusive) endpoint. 

364 

365 Notes 

366 ----- 

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

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

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

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

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

372 by our integer-time mapping. 

373 """ 

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

375 self.begin = begin 

376 self.end = end 

377 

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

379 

380 @classmethod 

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

382 # Docstring inherited. 

383 return ( 

384 ddl.FieldSpec( 

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

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

387 **kwargs, 

388 ), 

389 ddl.FieldSpec( 

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

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

392 **kwargs, 

393 ), 

394 ) 

395 

396 @classmethod 

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

398 # Docstring inherited. 

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

400 

401 @classmethod 

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

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

404 # Docstring inherited. 

405 if result is None: 

406 result = {} 

407 if timespan is None: 

408 begin = None 

409 end = None 

410 else: 

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

412 begin = EPOCH 

413 else: 

414 begin = timespan.begin 

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

416 end = MAX_TIME 

417 else: 

418 end = timespan.end 

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

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

421 return result 

422 

423 @classmethod 

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

425 # Docstring inherited. 

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

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

428 if begin is None: 

429 if end is not None: 

430 raise RuntimeError( 

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

432 ) 

433 return None 

434 elif end is None: 

435 raise RuntimeError( 

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

437 ) 

438 if times_equal(begin, EPOCH): 

439 begin = None 

440 elif begin < EPOCH: 

441 raise RuntimeError( 

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

443 ) 

444 if times_equal(end, MAX_TIME): 

445 end = None 

446 elif end > MAX_TIME: 

447 raise RuntimeError( 

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

449 ) 

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

451 

452 @classmethod 

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

454 # Docstring inherited. 

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

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

457 

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

459 # Docstring inherited. 

460 return self.begin.is_(None) 

461 

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

463 ) -> sqlalchemy.sql.ColumnElement: 

464 # Docstring inherited. 

465 if isinstance(other, Timespan): 

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

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

468 elif isinstance(other, _CompoundDatabaseTimespanRepresentation): 

469 begin = other.begin 

470 end = other.end 

471 else: 

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

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

474 

475 

476DatabaseTimespanRepresentation.Compound = _CompoundDatabaseTimespanRepresentation