Coverage for python/lsst/dax/apdb/apdb.py: 100%

69 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 17:49 +0000

1# This file is part of dax_apdb. 

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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ["Apdb", "ApdbConfig"] 

25 

26import logging 

27from abc import ABC, abstractmethod 

28from collections.abc import Iterable, Mapping 

29from typing import TYPE_CHECKING 

30 

31import astropy.time 

32import pandas 

33 

34from lsst.resources import ResourcePathExpression 

35from lsst.sphgeom import Region 

36 

37from .apdbSchema import ApdbSchema, ApdbTables 

38from .config import ApdbConfig 

39from .factory import make_apdb 

40from .recordIds import DiaForcedSourceId, DiaObjectId, DiaSourceId 

41from .schema_model import Table 

42 

43if TYPE_CHECKING: 

44 from .apdbAdmin import ApdbAdmin 

45 from .apdbMetadata import ApdbMetadata 

46 

47_LOG = logging.getLogger(__name__) 

48 

49 

50class Apdb(ABC): 

51 """Abstract interface for APDB.""" 

52 

53 @classmethod 

54 def from_config(cls, config: ApdbConfig) -> Apdb: 

55 """Create Ppdb instance from configuration object. 

56 

57 Parameters 

58 ---------- 

59 config : `ApdbConfig` 

60 Configuration object, type of this object determines type of the 

61 Apdb implementation. 

62 

63 Returns 

64 ------- 

65 apdb : `apdb` 

66 Instance of `Apdb` class. 

67 """ 

68 return make_apdb(config) 

69 

70 @classmethod 

71 def from_uri(cls, uri: ResourcePathExpression) -> Apdb: 

72 """Make Apdb instance from a serialized configuration. 

73 

74 Parameters 

75 ---------- 

76 uri : `~lsst.resources.ResourcePathExpression` 

77 URI or local file path pointing to a file with serialized 

78 configuration, or a string with a "label:" prefix. In the latter 

79 case, the configuration will be looked up from an APDB index file 

80 using the label name that follows the prefix. The APDB index file's 

81 location is determined by the ``DAX_APDB_INDEX_URI`` environment 

82 variable. 

83 

84 Returns 

85 ------- 

86 apdb : `apdb` 

87 Instance of `Apdb` class, the type of the returned instance is 

88 determined by configuration. 

89 """ 

90 config = ApdbConfig.from_uri(uri) 

91 return make_apdb(config) 

92 

93 @abstractmethod 

94 def close(self) -> None: 

95 """Release all resources associated with this Apdb instance. The 

96 instance may no longer be used after this is called. 

97 """ 

98 raise NotImplementedError() 

99 

100 @abstractmethod 

101 def getConfig(self) -> ApdbConfig: 

102 """Return APDB configuration for this instance, including any updates 

103 that may be read from database. 

104 

105 Returns 

106 ------- 

107 config : `ApdbConfig` 

108 APDB configuration. 

109 """ 

110 raise NotImplementedError() 

111 

112 @abstractmethod 

113 def tableDef(self, table: ApdbTables) -> Table | None: 

114 """Return table schema definition for a given table. 

115 

116 Parameters 

117 ---------- 

118 table : `ApdbTables` 

119 One of the known APDB tables. 

120 

121 Returns 

122 ------- 

123 tableSchema : `.schema_model.Table` or `None` 

124 Table schema description, `None` is returned if table is not 

125 defined by this implementation. 

126 """ 

127 raise NotImplementedError() 

128 

129 @abstractmethod 

130 def getDiaObjects(self, region: Region) -> pandas.DataFrame: 

131 """Return catalog of DiaObject instances from a given region. 

132 

133 This method returns only the last version of each DiaObject, 

134 and may return only the subset of the DiaObject columns needed 

135 for AP association. Some 

136 records in a returned catalog may be outside the specified region, it 

137 is up to a client to ignore those records or cleanup the catalog before 

138 futher use. 

139 

140 Parameters 

141 ---------- 

142 region : `lsst.sphgeom.Region` 

143 Region to search for DIAObjects. 

144 

145 Returns 

146 ------- 

147 catalog : `pandas.DataFrame` 

148 Catalog containing DiaObject records for a region that may be a 

149 superset of the specified region. 

150 """ 

151 raise NotImplementedError() 

152 

153 @abstractmethod 

154 def getDiaSources( 

155 self, 

156 region: Region, 

157 object_ids: Iterable[int] | None, 

158 visit_time: astropy.time.Time, 

159 start_time: astropy.time.Time | None = None, 

160 ) -> pandas.DataFrame | None: 

161 """Return catalog of DiaSource instances from a given region. 

162 

163 Parameters 

164 ---------- 

165 region : `lsst.sphgeom.Region` 

166 Region to search for DIASources. 

167 object_ids : iterable [ `int` ], optional 

168 List of DiaObject IDs to further constrain the set of returned 

169 sources. If `None` then returned sources are not constrained. If 

170 list is empty then empty catalog is returned with a correct 

171 schema. 

172 visit_time : `astropy.time.Time` 

173 Time of the current visit. If APDB contains records later than this 

174 time they may also be returned. 

175 start_time : `astropy.time.Time`, optional 

176 Lower bound of time window for the query. If not specified then 

177 it is calculated using ``visit_time`` and 

178 ``read_forced_sources_months`` configuration parameter. 

179 

180 Returns 

181 ------- 

182 catalog : `pandas.DataFrame`, or `None` 

183 Catalog containing DiaSource records. `None` is returned if 

184 ``start_time`` is not specified and ``read_sources_months`` 

185 configuration parameter is set to 0. 

186 

187 Notes 

188 ----- 

189 This method returns DiaSource catalog for a region with additional 

190 filtering based on DiaObject IDs. Only a subset of DiaSource history 

191 is returned limited by ``read_sources_months`` config parameter, w.r.t. 

192 ``visit_time``. If ``object_ids`` is empty then an empty catalog is 

193 always returned with the correct schema (columns/types). If 

194 ``object_ids`` is `None` then no filtering is performed and some of the 

195 returned records may be outside the specified region. 

196 """ 

197 raise NotImplementedError() 

198 

199 @abstractmethod 

200 def getDiaForcedSources( 

201 self, 

202 region: Region, 

203 object_ids: Iterable[int] | None, 

204 visit_time: astropy.time.Time, 

205 start_time: astropy.time.Time | None = None, 

206 ) -> pandas.DataFrame | None: 

207 """Return catalog of DiaForcedSource instances from a given region. 

208 

209 Parameters 

210 ---------- 

211 region : `lsst.sphgeom.Region` 

212 Region to search for DIASources. 

213 object_ids : iterable [ `int` ], optional 

214 List of DiaObject IDs to further constrain the set of returned 

215 sources. If list is empty then empty catalog is returned with a 

216 correct schema. If `None` then returned sources are not 

217 constrained. 

218 visit_time : `astropy.time.Time` 

219 Time of the current visit. If APDB contains records later than this 

220 time they may also be returned. 

221 start_time : `astropy.time.Time`, optional 

222 Lower bound of time window for the query. If not specified then 

223 it is calculated using ``visit_time`` and 

224 ``read_forced_sources_months`` configuration parameter. 

225 

226 Returns 

227 ------- 

228 catalog : `pandas.DataFrame`, or `None` 

229 Catalog containing DiaForcedSource records. `None` is returned if 

230 ``start_time`` is not specified and ``read_forced_sources_months`` 

231 configuration parameter is set to 0. 

232 

233 Raises 

234 ------ 

235 NotImplementedError 

236 May be raised by some implementations if ``object_ids`` is `None`. 

237 

238 Notes 

239 ----- 

240 This method returns DiaForcedSource catalog for a region with 

241 additional filtering based on DiaObject IDs. Only a subset of DiaSource 

242 history is returned limited by ``read_forced_sources_months`` config 

243 parameter, w.r.t. ``visit_time``. If ``object_ids`` is empty then an 

244 empty catalog is always returned with the correct schema 

245 (columns/types). If ``object_ids`` is `None` then no filtering is 

246 performed and some of the returned records may be outside the specified 

247 region. 

248 """ 

249 raise NotImplementedError() 

250 

251 @abstractmethod 

252 def getDiaObjectsForDedup(self, since: astropy.time.Time | None = None) -> pandas.DataFrame: 

253 """Return catalog of DiaObject stored in APDB since specified time. 

254 

255 This method should be used by deduplication algorithm to retrieve 

256 DiaObject records added to APDB since previous deduplication (typically 

257 during previous night). Returned catalog will have only a small subset 

258 of DiaObject attributes required by deduplication algorithm. 

259 

260 Parameters 

261 ---------- 

262 since : `astropy.time.Time`, optional 

263 Starting search time (time of previous deduplication). If not 

264 provided the time of the last deduplication stored in metadata 

265 by `resetDedup` method is used. 

266 

267 Returns 

268 ------- 

269 catalog : `pandas.DataFrame` 

270 Catalog containing DiaObject records, only a subset of attributes 

271 will be returned. 

272 """ 

273 raise NotImplementedError() 

274 

275 @abstractmethod 

276 def getDiaSourcesForDiaObjects( 

277 self, objects: list[DiaObjectId], start_time: astropy.time.Time, max_dist_arcsec: float = 1.0 

278 ) -> pandas.DataFrame: 

279 """Return catalog of DiaSources associated with given DiaObjects. 

280 

281 Parameters 

282 ---------- 

283 objects : `list` [`DiaObjectId`] 

284 DiaObjects associated with returned DiaSources. 

285 start_time : `astropy.time.Time` 

286 Lower bound for ``midpointMjdTai`` for returned DiaSources. 

287 max_dist_arcsec : `float` 

288 Maximum expected distance in arcsec between DiaSource and 

289 DiaObject. This parameter is used to optimize spatial queries in 

290 cases when DiaObject is located near the partition boundary. If the 

291 distance from DiaObject to the boundary is smaller than 

292 ``max_dist_arcsec``, then the neighbor partition will be included 

293 in search too. 

294 

295 Returns 

296 ------- 

297 catalog : `pandas.DataFrame` 

298 Catalog containing DiaSource records associated to given 

299 DiaObjects. 

300 

301 Notes 

302 ----- 

303 Primary purpose of this method is to support deduplication algorithm. 

304 Its implementation is likely to be very slow and inefficient, it should 

305 not be used for regular queries. 

306 """ 

307 raise NotImplementedError() 

308 

309 @abstractmethod 

310 def containsVisitDetector( 

311 self, 

312 visit: int, 

313 detector: int, 

314 region: Region | None = None, 

315 visit_time: astropy.time.Time | None = None, 

316 ) -> bool: 

317 """Test whether any sources for a given visit-detector are present in 

318 the APDB. 

319 

320 Parameters 

321 ---------- 

322 visit, detector : `int` 

323 The ID of the visit-detector to search for. 

324 region : `lsst.sphgeom.Region`, optional 

325 Deprecated - parameter is not used. Region corresponding to the 

326 visit/detector combination. 

327 visit_time : `astropy.time.Time`, optional 

328 Deprecated - parameter is not used. Visit time (as opposed to visit 

329 processing time). This can be any timestamp in the visit timespan, 

330 e.g. its begin or end time. 

331 

332 Returns 

333 ------- 

334 present : `bool` 

335 `True` if given visit/detector combination was stored in APDB, 

336 `False` otherwise. 

337 """ 

338 raise NotImplementedError() 

339 

340 @abstractmethod 

341 def store( 

342 self, 

343 visit_time: astropy.time.Time, 

344 objects: pandas.DataFrame, 

345 sources: pandas.DataFrame | None = None, 

346 forced_sources: pandas.DataFrame | None = None, 

347 ) -> None: 

348 """Store all three types of catalogs in the database. 

349 

350 Parameters 

351 ---------- 

352 visit_time : `astropy.time.Time` 

353 Time of the visit. 

354 objects : `pandas.DataFrame` 

355 Catalog with DiaObject records. 

356 sources : `pandas.DataFrame`, optional 

357 Catalog with DiaSource records. 

358 forced_sources : `pandas.DataFrame`, optional 

359 Catalog with DiaForcedSource records. 

360 

361 Notes 

362 ----- 

363 This methods takes DataFrame catalogs, their schema must be 

364 compatible with the schema of APDB table: 

365 

366 - column names must correspond to database table columns 

367 - types and units of the columns must match database definitions, 

368 no unit conversion is performed presently 

369 - columns that have default values in database schema can be 

370 omitted from catalog 

371 - this method knows how to fill interval-related columns of DiaObject 

372 (validityStart, validityEnd) they do not need to appear in a 

373 catalog 

374 - source catalogs have ``diaObjectId`` column associating sources 

375 with objects 

376 

377 This operation need not be atomic, but DiaSources and DiaForcedSources 

378 will not be stored until all DiaObjects are stored. 

379 """ 

380 raise NotImplementedError() 

381 

382 @abstractmethod 

383 def reassignDiaSourcesToDiaObjects( 

384 self, 

385 idMap: Mapping[DiaSourceId, int], 

386 *, 

387 increment_nDiaSources: bool = True, 

388 decrement_nDiaSources: bool = True, 

389 ) -> None: 

390 """Re-assign DiaSources from one DiaObject to another, typically 

391 during deduplication. 

392 

393 Parameters 

394 ---------- 

395 idMap : `~collections.abc.Mapping` [`DiaSourceId`, `int`] 

396 Mapping from DiaSource to their new ``diaObjectId``. 

397 increment_nDiaSources : `bool`, optional 

398 If `True` then increment the value of ``nDiaSources`` in DiaObjects 

399 that DiaSources are reassigned to. 

400 decrement_nDiaSources : `bool`, optional 

401 If `True` then decrement the value of ``nDiaSources`` in DiaObjects 

402 that DiaSources are reassigned from. 

403 

404 Raises 

405 ------ 

406 LookupError 

407 Raised if some of DiaSources or DiaObjects are not found. 

408 

409 Notes 

410 ----- 

411 DiaSources initially could be associated with SSObjects. This method 

412 needs to be called before `setValidityEnd`. 

413 """ 

414 raise NotImplementedError() 

415 

416 @abstractmethod 

417 def setValidityEnd( 

418 self, objects: list[DiaObjectId], validityEnd: astropy.time.Time, raise_on_missing_id: bool = False 

419 ) -> int: 

420 """Close validity interval for specified DiaObjects. 

421 

422 Parameters 

423 ---------- 

424 objects : `list` [`DiaObjectId`] 

425 DiaObjects which will have their validityEnd updated, if their 

426 current validityEnd is NULL. 

427 validityEnd : `astropy.time.Time` 

428 Value for validityEnd. 

429 raise_on_missing_id : `bool`, optional 

430 If `True` then `LookupError` will be raised if any object in the 

431 list is missing from the database. 

432 

433 Returns 

434 ------- 

435 count : `int` 

436 Actual number of records for which validityEnd was updated. 

437 

438 Raises 

439 ------ 

440 LookupError 

441 Raised if ``raise_on_missing_id`` is `True` and some of the 

442 specified DiaObjects could not be found in the database. 

443 

444 Notes 

445 ----- 

446 This method has to be called after `reassignDiaSourcesToDiaObjects`. 

447 """ 

448 raise NotImplementedError() 

449 

450 @abstractmethod 

451 def resetDedup(self, dedup_time: astropy.time.Time | None = None) -> None: 

452 """Delete deduplication-related data and remember deduplication time. 

453 Deduplication data generated before ``dedup_time`` will be erased. 

454 

455 Parameters 

456 ---------- 

457 dedup_time : `astropy.time.Time`, optional 

458 Time of the last deduplication, current time is used if not 

459 provided. 

460 """ 

461 raise NotImplementedError() 

462 

463 @abstractmethod 

464 def reassignDiaSources(self, idMap: Mapping[int, int]) -> None: 

465 """Associate DiaSources with SSObjects, dis-associating them 

466 from DiaObjects. 

467 

468 Parameters 

469 ---------- 

470 idMap : `Mapping` 

471 Maps DiaSource IDs to their new SSObject IDs. 

472 

473 Raises 

474 ------ 

475 ValueError 

476 Raised if DiaSource ID does not exist in the database. 

477 """ 

478 raise NotImplementedError() 

479 

480 @abstractmethod 

481 def withdrawDiaSources( 

482 self, 

483 diaSourceIds: Iterable[DiaSourceId], 

484 *, 

485 timeWithdrawn: astropy.time.Time | None = None, 

486 ) -> None: 

487 """Withdraw DiaSources. 

488 

489 Parameters 

490 ---------- 

491 diaSourceIds : `~collections.abc.Iterable` [`DiaSourceId`] 

492 Identifiers of DiaSources to withdraw. 

493 timeWithdrawn : `astropy.time.Time`, optional 

494 Set the value of ``time_withdrawn`` column to this time, current 

495 time by default. 

496 

497 Raises 

498 ------ 

499 LookupError 

500 Raised if some of DiaSources are not found. 

501 """ 

502 raise NotImplementedError() 

503 

504 @abstractmethod 

505 def withdrawDiaForcedSources( 

506 self, 

507 diaForcedSourceIds: Iterable[DiaForcedSourceId], 

508 *, 

509 timeWithdrawn: astropy.time.Time | None = None, 

510 ) -> None: 

511 """Withdraw DiaForcedSources. 

512 

513 Parameters 

514 ---------- 

515 diaForcedSourceIds : `~collections.abc.Iterable` [`DiaForcedSourceId`] 

516 Identifiers of DiaForcedSources to withdraw. 

517 timeWithdrawn : `astropy.time.Time`, optional 

518 Set the value of ``time_withdrawn`` column to this time, current 

519 time by default. 

520 

521 Raises 

522 ------ 

523 LookupError 

524 Raised if some of DiaForcedSources are not found. 

525 """ 

526 raise NotImplementedError() 

527 

528 @abstractmethod 

529 def countUnassociatedObjects(self) -> int: 

530 """Return the number of DiaObjects that have only one DiaSource 

531 associated with them. 

532 

533 Used as part of ap_verify metrics. 

534 

535 Returns 

536 ------- 

537 count : `int` 

538 Number of DiaObjects with exactly one associated DiaSource. 

539 

540 Notes 

541 ----- 

542 This method can be very inefficient or slow in some implementations. 

543 """ 

544 raise NotImplementedError() 

545 

546 @property 

547 @abstractmethod 

548 def schema(self) -> ApdbSchema: 

549 """APDB table schema from ``sdm_schemas`` (`ApdbSchema`).""" 

550 raise NotImplementedError() 

551 

552 @property 

553 @abstractmethod 

554 def metadata(self) -> ApdbMetadata: 

555 """Object controlling access to APDB metadata (`ApdbMetadata`).""" 

556 raise NotImplementedError() 

557 

558 @property 

559 @abstractmethod 

560 def admin(self) -> ApdbAdmin: 

561 """Object providing adminitrative interface for APDB (`ApdbAdmin`).""" 

562 raise NotImplementedError() 

563 

564 def _current_time(self) -> astropy.time.Time: 

565 """Return current system time. 

566 

567 Returns 

568 ------- 

569 current_time : `astropy.time.Time` 

570 Current time. 

571 

572 Notes 

573 ----- 

574 This method exists primarily for testing purposes, it can be 

575 monkey-patched in unit tests to return something else than current 

576 system time, if necessary. 

577 """ 

578 return astropy.time.Time.now()