Coverage for python / lsst / dax / apdb / sql / apdbSql.py: 12%

688 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 02:00 -0800

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 

22"""Module defining Apdb class and related methods.""" 

23 

24from __future__ import annotations 

25 

26__all__ = ["ApdbSql"] 

27 

28import datetime 

29import json 

30import logging 

31import urllib.parse 

32import uuid 

33import warnings 

34from collections import Counter 

35from collections.abc import Iterable, Mapping, MutableMapping 

36from contextlib import closing 

37from typing import TYPE_CHECKING, Any 

38 

39import astropy.time 

40import numpy as np 

41import pandas 

42import sqlalchemy 

43import sqlalchemy.dialects.postgresql 

44import sqlalchemy.dialects.sqlite 

45from sqlalchemy import func, sql 

46from sqlalchemy.pool import NullPool 

47 

48from lsst.sphgeom import HtmPixelization, LonLat, Region, UnitVector3d 

49from lsst.utils.db_auth import DbAuth, DbAuthNotFoundError 

50from lsst.utils.iteration import chunk_iterable 

51 

52from ..apdb import Apdb 

53from ..apdbConfigFreezer import ApdbConfigFreezer 

54from ..apdbReplica import ReplicaChunk 

55from ..apdbSchema import ApdbSchema, ApdbTables 

56from ..apdbUpdateRecord import ( 

57 ApdbCloseDiaObjectValidityRecord, 

58 ApdbReassignDiaSourceToDiaObjectRecord, 

59 ApdbUpdateNDiaSourcesRecord, 

60 ApdbUpdateRecord, 

61) 

62from ..config import ApdbConfig 

63from ..monitor import MonAgent 

64from ..recordIds import DiaObjectId, DiaSourceId 

65from ..schema_model import Table 

66from ..timer import Timer 

67from ..versionTuple import IncompatibleVersionError, VersionTuple 

68from .apdbMetadataSql import ApdbMetadataSql 

69from .apdbSqlAdmin import ApdbSqlAdmin 

70from .apdbSqlReplica import ApdbSqlReplica 

71from .apdbSqlSchema import ApdbSqlSchema, ExtraTables 

72from .config import ApdbSqlConfig 

73 

74if TYPE_CHECKING: 

75 import sqlite3 

76 

77 from ..apdbMetadata import ApdbMetadata 

78 from ..apdbUpdateRecord import ApdbUpdateRecord 

79 

80_LOG = logging.getLogger(__name__) 

81 

82_MON = MonAgent(__name__) 

83 

84VERSION = VersionTuple(1, 2, 1) 

85"""Version for the code controlling non-replication tables. This needs to be 

86updated following compatibility rules when schema produced by this code 

87changes. 

88""" 

89 

90 

91def _coerce_uint64(df: pandas.DataFrame) -> pandas.DataFrame: 

92 """Change the type of uint64 columns to int64, and return copy of data 

93 frame. 

94 """ 

95 names = [c[0] for c in df.dtypes.items() if c[1] == np.uint64] 

96 return df.astype(dict.fromkeys(names, np.int64)) 

97 

98 

99def _make_midpointMjdTai_start(visit_time: astropy.time.Time, months: int) -> float: 

100 """Calculate starting point for time-based source search. 

101 

102 Parameters 

103 ---------- 

104 visit_time : `astropy.time.Time` 

105 Time of current visit. 

106 months : `int` 

107 Number of months in the sources history. 

108 

109 Returns 

110 ------- 

111 time : `float` 

112 A ``midpointMjdTai`` starting point, MJD time. 

113 """ 

114 # TODO: Use of MJD must be consistent with the code in ap_association 

115 # (see DM-31996) 

116 return float(visit_time.tai.mjd) - months * 30 

117 

118 

119def _onSqlite3Connect( 

120 dbapiConnection: sqlite3.Connection, connectionRecord: sqlalchemy.pool._ConnectionRecord 

121) -> None: 

122 # Enable foreign keys 

123 with closing(dbapiConnection.cursor()) as cursor: 

124 cursor.execute("PRAGMA foreign_keys=ON;") 

125 

126 

127class ApdbSql(Apdb): 

128 """Implementation of APDB interface based on SQL database. 

129 

130 The implementation is configured via standard ``pex_config`` mechanism 

131 using `ApdbSqlConfig` configuration class. For an example of different 

132 configurations check ``config/`` folder. 

133 

134 Parameters 

135 ---------- 

136 config : `ApdbSqlConfig` 

137 Configuration object. 

138 """ 

139 

140 metadataSchemaVersionKey = "version:schema" 

141 """Name of the metadata key to store schema version number.""" 

142 

143 metadataCodeVersionKey = "version:ApdbSql" 

144 """Name of the metadata key to store code version number.""" 

145 

146 metadataReplicaVersionKey = "version:ApdbSqlReplica" 

147 """Name of the metadata key to store replica code version number.""" 

148 

149 metadataConfigKey = "config:apdb-sql.json" 

150 """Name of the metadata key to store code version number.""" 

151 

152 metadataDedupKey = "status:deduplication.json" 

153 """Name of the metadata key to store code version number.""" 

154 

155 _frozen_parameters = ( 

156 "enable_replica", 

157 "dia_object_index", 

158 "pixelization.htm_level", 

159 "pixelization.htm_index_column", 

160 "ra_dec_columns", 

161 ) 

162 """Names of the config parameters to be frozen in metadata table.""" 

163 

164 def __init__(self, config: ApdbSqlConfig): 

165 self._engine = self._makeEngine(config, create=False) 

166 

167 sa_metadata = sqlalchemy.MetaData(schema=config.namespace) 

168 meta_table_name = ApdbTables.metadata.table_name(prefix=config.prefix) 

169 meta_table = sqlalchemy.schema.Table(meta_table_name, sa_metadata, autoload_with=self._engine) 

170 self._metadata = ApdbMetadataSql(self._engine, meta_table) 

171 

172 # Get tables schemas. 

173 self._table_schema = ApdbSchema(config.schema_file, config.ss_schema_file) 

174 

175 # Check that versions are compatible, must be the first thing before 

176 # reading frozen config. 

177 self._db_schema_version = self._versionCheck(self._metadata, self._table_schema.schemaVersion()) 

178 

179 # Read frozen config from metadata. 

180 config_json = self._metadata.get(self.metadataConfigKey) 

181 if config_json is not None: 

182 # Update config from metadata. 

183 freezer = ApdbConfigFreezer[ApdbSqlConfig](self._frozen_parameters) 

184 self.config = freezer.update(config, config_json) 

185 else: 

186 self.config = config 

187 

188 self._schema = ApdbSqlSchema( 

189 table_schema=self._table_schema, 

190 engine=self._engine, 

191 dia_object_index=self.config.dia_object_index, 

192 prefix=self.config.prefix, 

193 namespace=self.config.namespace, 

194 htm_index_column=self.config.pixelization.htm_index_column, 

195 enable_replica=self.config.enable_replica, 

196 ) 

197 

198 self.pixelator = HtmPixelization(self.config.pixelization.htm_level) 

199 

200 if _LOG.isEnabledFor(logging.DEBUG): 

201 _LOG.debug("ApdbSql Configuration: %s", self.config.model_dump()) 

202 

203 def _timer(self, name: str, *, tags: Mapping[str, str | int] | None = None) -> Timer: 

204 """Create `Timer` instance given its name.""" 

205 return Timer(name, _MON, tags=tags) 

206 

207 @classmethod 

208 def _makeEngine(cls, config: ApdbSqlConfig, *, create: bool) -> sqlalchemy.engine.Engine: 

209 """Make SQLALchemy engine based on configured parameters. 

210 

211 Parameters 

212 ---------- 

213 config : `ApdbSqlConfig` 

214 Configuration object. 

215 create : `bool` 

216 Whether to try to create new database file, only relevant for 

217 SQLite backend which always creates new files by default. 

218 """ 

219 # engine is reused between multiple processes, make sure that we don't 

220 # share connections by disabling pool (by using NullPool class) 

221 kw: MutableMapping[str, Any] = dict(config.connection_config.extra_parameters) 

222 conn_args: dict[str, Any] = {} 

223 if not config.connection_config.connection_pool: 

224 kw.update(poolclass=NullPool) 

225 if config.connection_config.isolation_level is not None: 

226 kw.update(isolation_level=config.connection_config.isolation_level) 

227 elif config.db_url.startswith("sqlite"): 

228 # Use READ_UNCOMMITTED as default value for sqlite. 

229 kw.update(isolation_level="READ_UNCOMMITTED") 

230 if config.connection_config.connection_timeout is not None: 

231 if config.db_url.startswith("sqlite"): 

232 conn_args.update(timeout=config.connection_config.connection_timeout) 

233 elif config.db_url.startswith(("postgresql", "mysql")): 

234 conn_args.update(connect_timeout=int(config.connection_config.connection_timeout)) 

235 kw.update(connect_args=conn_args) 

236 engine = sqlalchemy.create_engine(cls._connection_url(config.db_url, create=create), **kw) 

237 

238 if engine.dialect.name == "sqlite": 

239 # Need to enable foreign keys on every new connection. 

240 sqlalchemy.event.listen(engine, "connect", _onSqlite3Connect) 

241 

242 return engine 

243 

244 @classmethod 

245 def _connection_url(cls, config_url: str, *, create: bool) -> sqlalchemy.engine.URL | str: 

246 """Generate a complete URL for database with proper credentials. 

247 

248 Parameters 

249 ---------- 

250 config_url : `str` 

251 Database URL as specified in configuration. 

252 create : `bool` 

253 Whether to try to create new database file, only relevant for 

254 SQLite backend which always creates new files by default. 

255 

256 Returns 

257 ------- 

258 connection_url : `sqlalchemy.engine.URL` or `str` 

259 Connection URL including credentials. 

260 """ 

261 # Allow 3rd party authentication mechanisms by assuming connection 

262 # string is correct when we can not recognize (dialect, host, database) 

263 # matching keys. 

264 components = urllib.parse.urlparse(config_url) 

265 if all((components.scheme is not None, components.hostname is not None, components.path is not None)): 

266 try: 

267 db_auth = DbAuth() 

268 config_url = db_auth.getUrl(config_url) 

269 except DbAuthNotFoundError: 

270 # Credentials file doesn't exist or no matching credentials, 

271 # use default auth. 

272 pass 

273 

274 # SQLite has a nasty habit creating empty databases when they do not 

275 # exist, tell it not to do that unless we do need to create it. 

276 if not create: 

277 config_url = cls._update_sqlite_url(config_url) 

278 

279 return config_url 

280 

281 @classmethod 

282 def _update_sqlite_url(cls, url_string: str) -> str: 

283 """If URL refers to sqlite dialect, update it so that the backend does 

284 not try to create database file if it does not exist already. 

285 

286 Parameters 

287 ---------- 

288 url_string : `str` 

289 Connection string. 

290 

291 Returns 

292 ------- 

293 url_string : `str` 

294 Possibly updated connection string. 

295 """ 

296 try: 

297 url = sqlalchemy.make_url(url_string) 

298 except sqlalchemy.exc.SQLAlchemyError: 

299 # If parsing fails it means some special format, likely not 

300 # sqlite so we just return it unchanged. 

301 return url_string 

302 

303 if url.get_backend_name() == "sqlite": 

304 # Massage url so that database name starts with "file:" and 

305 # option string has "mode=rw&uri=true". Database name 

306 # should look like a path (:memory: is not supported by 

307 # Apdb, but someone could still try to use it). 

308 database = url.database 

309 if database and not database.startswith((":", "file:")): 

310 query = dict(url.query, mode="rw", uri="true") 

311 # If ``database`` is an absolute path then original URL should 

312 # include four slashes after "sqlite:". Humans are bad at 

313 # counting things beyond four and sometimes an extra slash gets 

314 # added unintentionally, which causes sqlite to treat initial 

315 # element as "authority" and to complain. Strip extra slashes 

316 # at the start of the path to avoid that (DM-46077). 

317 if database.startswith("//"): 

318 warnings.warn( 

319 f"Database URL contains extra leading slashes which will be removed: {url}", 

320 stacklevel=3, 

321 ) 

322 database = "/" + database.lstrip("/") 

323 url = url.set(database=f"file:{database}", query=query) 

324 url_string = url.render_as_string() 

325 

326 return url_string 

327 

328 @classmethod 

329 def _versionCheck(cls, metadata: ApdbMetadataSql, schema_version: VersionTuple) -> VersionTuple: 

330 """Check schema version compatibility and return the database schema 

331 version. 

332 """ 

333 

334 def _get_version(key: str) -> VersionTuple: 

335 """Retrieve version number from given metadata key.""" 

336 version_str = metadata.get(key) 

337 if version_str is None: 

338 # Should not happen with existing metadata table. 

339 raise RuntimeError(f"Version key {key!r} does not exist in metadata table.") 

340 return VersionTuple.fromString(version_str) 

341 

342 db_schema_version = _get_version(cls.metadataSchemaVersionKey) 

343 db_code_version = _get_version(cls.metadataCodeVersionKey) 

344 

345 # For now there is no way to make read-only APDB instances, assume that 

346 # any access can do updates. 

347 if not schema_version.checkCompatibility(db_schema_version): 

348 raise IncompatibleVersionError( 

349 f"Configured schema version {schema_version} " 

350 f"is not compatible with database version {db_schema_version}" 

351 ) 

352 if not cls.apdbImplementationVersion().checkCompatibility(db_code_version): 

353 raise IncompatibleVersionError( 

354 f"Current code version {cls.apdbImplementationVersion()} " 

355 f"is not compatible with database version {db_code_version}" 

356 ) 

357 

358 # Check replica code version only if replica is enabled. Sort of 

359 # chicken and egg problem - `enable_replica` is a part of frozen 

360 # configuration, but we cannot read frozen configuration until we 

361 # validate versions. Assume that if the replica version is present 

362 # then replication is enabled. 

363 if metadata.get(cls.metadataReplicaVersionKey) is not None: 

364 db_replica_version = _get_version(cls.metadataReplicaVersionKey) 

365 code_replica_version = ApdbSqlReplica.apdbReplicaImplementationVersion() 

366 if not code_replica_version.checkCompatibility(db_replica_version): 

367 raise IncompatibleVersionError( 

368 f"Current replication code version {code_replica_version} " 

369 f"is not compatible with database version {db_replica_version}" 

370 ) 

371 

372 return db_schema_version 

373 

374 @classmethod 

375 def apdbImplementationVersion(cls) -> VersionTuple: 

376 """Return version number for current APDB implementation. 

377 

378 Returns 

379 ------- 

380 version : `VersionTuple` 

381 Version of the code defined in implementation class. 

382 """ 

383 return VERSION 

384 

385 @classmethod 

386 def init_database( 

387 cls, 

388 db_url: str, 

389 *, 

390 schema_file: str | None = None, 

391 ss_schema_file: str | None = None, 

392 read_sources_months: int | None = None, 

393 read_forced_sources_months: int | None = None, 

394 enable_replica: bool = False, 

395 connection_timeout: int | None = None, 

396 dia_object_index: str | None = None, 

397 htm_level: int | None = None, 

398 htm_index_column: str | None = None, 

399 ra_dec_columns: tuple[str, str] | None = None, 

400 prefix: str | None = None, 

401 namespace: str | None = None, 

402 drop: bool = False, 

403 ) -> ApdbSqlConfig: 

404 """Initialize new APDB instance and make configuration object for it. 

405 

406 Parameters 

407 ---------- 

408 db_url : `str` 

409 SQLAlchemy database URL. 

410 schema_file : `str`, optional 

411 Location of (YAML) configuration file with APDB schema. If not 

412 specified then default location will be used. 

413 ss_schema_file : `str`, optional 

414 Location of (YAML) configuration file with SSO schema. If not 

415 specified then default location will be used. 

416 read_sources_months : `int`, optional 

417 Number of months of history to read from DiaSource. 

418 read_forced_sources_months : `int`, optional 

419 Number of months of history to read from DiaForcedSource. 

420 enable_replica : `bool`, optional 

421 If True, make additional tables used for replication to PPDB. 

422 connection_timeout : `int`, optional 

423 Database connection timeout in seconds. 

424 dia_object_index : `str`, optional 

425 Indexing mode for DiaObject table. 

426 htm_level : `int`, optional 

427 HTM indexing level. 

428 htm_index_column : `str`, optional 

429 Name of a HTM index column for DiaObject and DiaSource tables. 

430 ra_dec_columns : `tuple` [`str`, `str`], optional 

431 Names of ra/dec columns in DiaObject table. 

432 prefix : `str`, optional 

433 Optional prefix for all table names. 

434 namespace : `str`, optional 

435 Name of the database schema for all APDB tables. If not specified 

436 then default schema is used. 

437 drop : `bool`, optional 

438 If `True` then drop existing tables before re-creating the schema. 

439 

440 Returns 

441 ------- 

442 config : `ApdbSqlConfig` 

443 Resulting configuration object for a created APDB instance. 

444 """ 

445 config = ApdbSqlConfig(db_url=db_url, enable_replica=enable_replica) 

446 if schema_file is not None: 

447 config.schema_file = schema_file 

448 if ss_schema_file is not None: 

449 config.ss_schema_file = ss_schema_file 

450 if read_sources_months is not None: 

451 config.read_sources_months = read_sources_months 

452 if read_forced_sources_months is not None: 

453 config.read_forced_sources_months = read_forced_sources_months 

454 if connection_timeout is not None: 

455 config.connection_config.connection_timeout = connection_timeout 

456 if dia_object_index is not None: 

457 config.dia_object_index = dia_object_index 

458 if htm_level is not None: 

459 config.pixelization.htm_level = htm_level 

460 if htm_index_column is not None: 

461 config.pixelization.htm_index_column = htm_index_column 

462 if ra_dec_columns is not None: 

463 config.ra_dec_columns = ra_dec_columns 

464 if prefix is not None: 

465 config.prefix = prefix 

466 if namespace is not None: 

467 config.namespace = namespace 

468 

469 cls._makeSchema(config, drop=drop) 

470 

471 # SQLite has a nasty habit of creating empty database by default, 

472 # update URL in config file to disable that behavior. 

473 config.db_url = cls._update_sqlite_url(config.db_url) 

474 

475 return config 

476 

477 def get_replica(self) -> ApdbSqlReplica: 

478 """Return `ApdbReplica` instance for this database.""" 

479 return ApdbSqlReplica(self._schema, self._engine, self._db_schema_version) 

480 

481 def tableRowCount(self) -> dict[str, int]: 

482 """Return dictionary with the table names and row counts. 

483 

484 Used by ``ap_proto`` to keep track of the size of the database tables. 

485 Depending on database technology this could be expensive operation. 

486 

487 Returns 

488 ------- 

489 row_counts : `dict` 

490 Dict where key is a table name and value is a row count. 

491 """ 

492 res = {} 

493 tables = [ApdbTables.DiaObject, ApdbTables.DiaSource, ApdbTables.DiaForcedSource] 

494 if self.config.dia_object_index == "last_object_table": 

495 tables.append(ApdbTables.DiaObjectLast) 

496 with self._engine.begin() as conn: 

497 for table in tables: 

498 sa_table = self._schema.get_table(table) 

499 stmt = sql.select(func.count()).select_from(sa_table) 

500 count: int = conn.execute(stmt).scalar_one() 

501 res[table.name] = count 

502 

503 return res 

504 

505 def getConfig(self) -> ApdbSqlConfig: 

506 # docstring is inherited from a base class 

507 return self.config 

508 

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

510 # docstring is inherited from a base class 

511 return self._schema.tableSchemas.get(table) 

512 

513 @classmethod 

514 def _makeSchema(cls, config: ApdbConfig, drop: bool = False) -> None: 

515 # docstring is inherited from a base class 

516 

517 if not isinstance(config, ApdbSqlConfig): 

518 raise TypeError(f"Unexpected type of configuration object: {type(config)}") 

519 

520 engine = cls._makeEngine(config, create=True) 

521 

522 table_schema = ApdbSchema(config.schema_file, config.ss_schema_file) 

523 

524 # Ask schema class to create all tables. 

525 schema = ApdbSqlSchema( 

526 table_schema=table_schema, 

527 engine=engine, 

528 dia_object_index=config.dia_object_index, 

529 prefix=config.prefix, 

530 namespace=config.namespace, 

531 htm_index_column=config.pixelization.htm_index_column, 

532 enable_replica=config.enable_replica, 

533 ) 

534 schema.makeSchema(drop=drop) 

535 

536 # Need metadata table to store few items in it. 

537 meta_table = schema.get_table(ApdbTables.metadata) 

538 apdb_meta = ApdbMetadataSql(engine, meta_table) 

539 

540 # Fill version numbers, overwrite if they are already there. 

541 apdb_meta.set(cls.metadataSchemaVersionKey, str(table_schema.schemaVersion()), force=True) 

542 apdb_meta.set(cls.metadataCodeVersionKey, str(cls.apdbImplementationVersion()), force=True) 

543 if config.enable_replica: 

544 # Only store replica code version if replica is enabled. 

545 apdb_meta.set( 

546 cls.metadataReplicaVersionKey, 

547 str(ApdbSqlReplica.apdbReplicaImplementationVersion()), 

548 force=True, 

549 ) 

550 

551 # Store frozen part of a configuration in metadata. 

552 freezer = ApdbConfigFreezer[ApdbSqlConfig](cls._frozen_parameters) 

553 apdb_meta.set(cls.metadataConfigKey, freezer.to_json(config), force=True) 

554 

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

556 # docstring is inherited from a base class 

557 

558 # decide what columns we need 

559 if self.config.dia_object_index == "last_object_table": 

560 table_enum = ApdbTables.DiaObjectLast 

561 else: 

562 table_enum = ApdbTables.DiaObject 

563 table = self._schema.get_table(table_enum) 

564 if not self.config.dia_object_columns: 

565 columns = self._schema.get_apdb_columns(table_enum) 

566 else: 

567 columns = [table.c[col] for col in self.config.dia_object_columns] 

568 query = sql.select(*columns) 

569 

570 # build selection 

571 query = query.where(self._filterRegion(table, region)) 

572 

573 validity_end_column = self._timestamp_column_name("validityEnd") 

574 

575 # select latest version of objects 

576 if self.config.dia_object_index != "last_object_table": 

577 query = query.where(table.columns[validity_end_column] == None) # noqa: E711 

578 

579 # _LOG.debug("query: %s", query) 

580 

581 # execute select 

582 with self._timer("select_time", tags={"table": "DiaObject"}) as timer: 

583 with self._engine.begin() as conn: 

584 objects = pandas.read_sql_query(query, conn) 

585 timer.add_values(row_count=len(objects)) 

586 _LOG.debug("found %s DiaObjects", len(objects)) 

587 return self._fix_result_timestamps(objects) 

588 

589 def getDiaSources( 

590 self, 

591 region: Region, 

592 object_ids: Iterable[int] | None, 

593 visit_time: astropy.time.Time, 

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

595 ) -> pandas.DataFrame | None: 

596 # docstring is inherited from a base class 

597 if start_time is None and self.config.read_sources_months == 0: 

598 _LOG.debug("Skip DiaSources fetching") 

599 return None 

600 

601 if start_time is None: 

602 start_time_mjdTai = _make_midpointMjdTai_start(visit_time, self.config.read_sources_months) 

603 else: 

604 start_time_mjdTai = float(start_time.tai.mjd) 

605 _LOG.debug("start_time_mjdTai = %.6f", start_time_mjdTai) 

606 

607 if object_ids is None: 

608 # region-based select 

609 return self._getDiaSourcesInRegion(region, start_time_mjdTai) 

610 else: 

611 return self._getDiaSourcesByIDs(list(object_ids), start_time_mjdTai) 

612 

613 def getDiaForcedSources( 

614 self, 

615 region: Region, 

616 object_ids: Iterable[int] | None, 

617 visit_time: astropy.time.Time, 

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

619 ) -> pandas.DataFrame | None: 

620 # docstring is inherited from a base class 

621 if start_time is None and self.config.read_forced_sources_months == 0: 

622 _LOG.debug("Skip DiaForceSources fetching") 

623 return None 

624 

625 if object_ids is None: 

626 # This implementation does not support region-based selection. In 

627 # the past DiaForcedSource schema did not have ra/dec columns (it 

628 # had x/y columns). ra/dec were added at some point, so we could 

629 # add pixelOd column to this table if/when needed. 

630 raise NotImplementedError("Region-based selection is not supported") 

631 

632 # TODO: DateTime.MJD must be consistent with code in ap_association, 

633 # alternatively we can fill midpointMjdTai ourselves in store() 

634 if start_time is None: 

635 start_time_mjdTai = _make_midpointMjdTai_start(visit_time, self.config.read_forced_sources_months) 

636 else: 

637 start_time_mjdTai = float(start_time.tai.mjd) 

638 _LOG.debug("start_time_mjdTai = %.6f", start_time_mjdTai) 

639 

640 with self._timer("select_time", tags={"table": "DiaForcedSource"}) as timer: 

641 sources = self._getSourcesByIDs(ApdbTables.DiaForcedSource, list(object_ids), start_time_mjdTai) 

642 timer.add_values(row_count=len(sources)) 

643 

644 _LOG.debug("found %s DiaForcedSources", len(sources)) 

645 return sources 

646 

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

648 # docstring is inherited from a base class 

649 

650 if since is None: 

651 # Read last deduplication time from metadata. 

652 dedup_str = self._metadata.get(self.metadataDedupKey) 

653 if dedup_str is not None: 

654 dedup_state = json.loads(dedup_str) 

655 dedup_time_str = dedup_state["dedup_time_iso_tai"] 

656 since = astropy.time.Time(dedup_time_str, format="iso", scale="tai") 

657 

658 validity_start_column = self._timestamp_column_name("validityStart") 

659 

660 # decide what columns we need 

661 if self.config.dia_object_index == "last_object_table": 

662 table_enum = ApdbTables.DiaObjectLast 

663 else: 

664 table_enum = ApdbTables.DiaObject 

665 table = self._schema.get_table(table_enum) 

666 

667 if not self.config.dia_object_columns_for_dedup: 

668 columns = self._schema.get_apdb_columns(table_enum) 

669 else: 

670 column_names = list(self.config.dia_object_columns_for_dedup) 

671 if validity_start_column not in column_names: 

672 column_names.insert(0, validity_start_column) 

673 if "diaObjectId" not in column_names: 

674 column_names.insert(0, "diaObjectId") 

675 columns = [table.columns[col] for col in column_names] 

676 

677 query = sql.select(*columns) 

678 

679 # build selection 

680 if since is not None: 

681 timestamp = self._timestamp_column_value(since) 

682 query = query.where(table.columns[validity_start_column] >= timestamp) 

683 

684 # execute select 

685 with self._timer("select_time", tags={"table": "DiaObject"}) as timer: 

686 with self._engine.begin() as conn: 

687 objects = pandas.read_sql_query(query, conn) 

688 timer.add_values(row_count=len(objects)) 

689 _LOG.debug("found %s DiaObjects", len(objects)) 

690 return self._fix_result_timestamps(objects) 

691 

692 def getDiaSourcesForDiaObjects( 

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

694 ) -> pandas.DataFrame: 

695 # docstring is inherited from a base class 

696 object_ids = {object_id.diaObjectId for object_id in objects} 

697 return self._getDiaSourcesByIDs(list(object_ids), float(start_time.tai.mjd)) 

698 

699 def containsVisitDetector( 

700 self, 

701 visit: int, 

702 detector: int, 

703 region: Region, 

704 visit_time: astropy.time.Time, 

705 ) -> bool: 

706 # docstring is inherited from a base class 

707 src_table: sqlalchemy.schema.Table = self._schema.get_table(ApdbTables.DiaSource) 

708 frcsrc_table: sqlalchemy.schema.Table = self._schema.get_table(ApdbTables.DiaForcedSource) 

709 # Query should load only one leaf page of the index 

710 query1 = sql.select(src_table.c.visit).filter_by(visit=visit, detector=detector).limit(1) 

711 

712 with self._engine.begin() as conn: 

713 result = conn.execute(query1).scalar_one_or_none() 

714 if result is not None: 

715 return True 

716 else: 

717 # Backup query if an image was processed but had no diaSources 

718 query2 = sql.select(frcsrc_table.c.visit).filter_by(visit=visit, detector=detector).limit(1) 

719 result = conn.execute(query2).scalar_one_or_none() 

720 return result is not None 

721 

722 def store( 

723 self, 

724 visit_time: astropy.time.Time, 

725 objects: pandas.DataFrame, 

726 sources: pandas.DataFrame | None = None, 

727 forced_sources: pandas.DataFrame | None = None, 

728 ) -> None: 

729 # docstring is inherited from a base class 

730 objects = self._fix_input_timestamps(objects) 

731 if sources is not None: 

732 sources = self._fix_input_timestamps(sources) 

733 if forced_sources is not None: 

734 forced_sources = self._fix_input_timestamps(forced_sources) 

735 

736 # We want to run all inserts in one transaction. 

737 with self._engine.begin() as connection: 

738 replica_chunk: ReplicaChunk | None = None 

739 if self._schema.replication_enabled: 

740 replica_chunk = ReplicaChunk.make_replica_chunk(visit_time, self.config.replica_chunk_seconds) 

741 self._storeReplicaChunk(replica_chunk, connection) 

742 

743 # fill pixelId column for DiaObjects 

744 objects = self._add_spatial_index(objects) 

745 self._storeDiaObjects(objects, visit_time, replica_chunk, connection) 

746 

747 if sources is not None: 

748 # fill pixelId column for DiaSources 

749 sources = self._add_spatial_index(sources) 

750 self._storeDiaSources(sources, replica_chunk, connection) 

751 

752 if forced_sources is not None: 

753 self._storeDiaForcedSources(forced_sources, replica_chunk, connection) 

754 

755 def reassignDiaSourcesToDiaObjects( 

756 self, 

757 idMap: Mapping[DiaSourceId, int], 

758 *, 

759 increment_nDiaSources: bool = True, 

760 decrement_nDiaSources: bool = True, 

761 ) -> None: 

762 # docstring is inherited from a base class 

763 

764 new_object_ids = set(idMap.values()) 

765 source_ids = {source.diaSourceId for source in idMap} 

766 

767 current_time = self._current_time() 

768 current_time_ns = int(current_time.unix_tai * 1e9) 

769 

770 with self._engine.begin() as conn: 

771 # Make sure that all DiaSources exist. 

772 found_sources = self._get_diasource_data(conn, source_ids, "diaObjectId") 

773 if missing_ids := (source_ids - {row.diaSourceId for row in found_sources}): 

774 raise LookupError(f"Some source IDs are missing from DiaSource table: {missing_ids}") 

775 original_object_ids = {row.diaSourceId: row.diaObjectId for row in found_sources} 

776 

777 # Make sure that all DiaObjects exist, we also want to know 

778 # nDiaSources count for current and new records because we want to 

779 # send updated values to replica. 

780 all_object_ids = new_object_ids | set(original_object_ids.values()) 

781 found_objects = self._get_diaobject_data(conn, all_object_ids, "ra", "dec", "nDiaSources") 

782 if missing_ids := (new_object_ids - {row.diaObjectId for row in found_objects}): 

783 raise LookupError(f"Some object IDs are missing from DiaObject table: {missing_ids}") 

784 

785 found_objects_by_id = {row.diaObjectId: row for row in found_objects} 

786 

787 update_records: list[ApdbUpdateRecord] = [] 

788 update_order = 0 

789 

790 # Update DiaSources. 

791 source_table = self._schema.get_table(ApdbTables.DiaSource) 

792 for source, diaObjectId in idMap.items(): 

793 update = ( 

794 source_table.update() 

795 .where(source_table.columns["diaSourceId"] == source.diaSourceId) 

796 .values(diaObjectId=diaObjectId) 

797 ) 

798 conn.execute(update) 

799 

800 if self._schema.replication_enabled: 

801 update_records.append( 

802 ApdbReassignDiaSourceToDiaObjectRecord( 

803 diaSourceId=source.diaSourceId, 

804 ra=source.ra, 

805 dec=source.dec, 

806 midpointMjdTai=source.midpointMjdTai, 

807 diaObjectId=diaObjectId, 

808 update_time_ns=current_time_ns, 

809 update_order=update_order, 

810 ) 

811 ) 

812 update_order += 1 

813 

814 # DiaObject tables to update. 

815 object_tables = [self._schema.get_table(ApdbTables.DiaObject)] 

816 if self.config.dia_object_index == "last_object_table": 

817 object_tables.append(self._schema.get_table(ApdbTables.DiaObjectLast)) 

818 

819 # Things to increment/decrement. 

820 increments: Counter = Counter() 

821 if increment_nDiaSources: 

822 increments.update(idMap.values()) 

823 if decrement_nDiaSources: 

824 increments.subtract(original_object_ids[source_id.diaSourceId] for source_id in idMap) 

825 

826 if increments: 

827 for table in object_tables: 

828 for diaObjectId, increment in increments.items(): 

829 update = ( 

830 table.update() 

831 .where(table.columns["diaObjectId"] == diaObjectId) 

832 .values(nDiaSources=table.columns["nDiaSources"] + increment) 

833 ) 

834 conn.execute(update) 

835 

836 # Also send updated values to replica. 

837 if self._schema.replication_enabled: 

838 for diaObjectId, increment in increments.items(): 

839 dia_object = found_objects_by_id[diaObjectId] 

840 update_records.append( 

841 ApdbUpdateNDiaSourcesRecord( 

842 diaObjectId=diaObjectId, 

843 ra=dia_object.ra, 

844 dec=dia_object.dec, 

845 nDiaSources=dia_object.nDiaSources + increment, 

846 update_time_ns=current_time_ns, 

847 update_order=update_order, 

848 ) 

849 ) 

850 update_order += 1 

851 

852 if update_records: 

853 replica_chunk = ReplicaChunk.make_replica_chunk( 

854 current_time, self.config.replica_chunk_seconds 

855 ) 

856 self._storeUpdateRecords(update_records, replica_chunk, connection=conn, store_chunk=True) 

857 

858 def setValidityEnd( 

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

860 ) -> int: 

861 # docstring is inherited from a base class 

862 if not objects: 

863 return 0 

864 

865 requested_ids = {obj.diaObjectId for obj in objects} 

866 

867 validity_end_column = self._timestamp_column_name("validityEnd") 

868 validityEnd_value = self._timestamp_column_value(validityEnd) 

869 

870 # Find all matching DiaObjects with validityEnd = NULL. 

871 table = self._schema.get_table(ApdbTables.DiaObject) 

872 query = sql.select(table.columns["diaObjectId"]).where( 

873 sqlalchemy.and_( 

874 table.columns["diaObjectId"].in_(sorted(requested_ids)), 

875 table.columns[validity_end_column].is_(None), 

876 ) 

877 ) 

878 

879 with self._engine.begin() as conn: 

880 result = conn.execute(query) 

881 found_ids = set(result.scalars()) 

882 

883 # Check that we found all that is requested. 

884 if raise_on_missing_id: 

885 if missing_ids := (requested_ids - found_ids): 

886 raise LookupError(f"Some object IDs are missing from DiaObjectLast table: {missing_ids}") 

887 

888 # Filter existing records. 

889 if len(objects) != len(found_ids): 

890 objects = [obj for obj in objects if obj.diaObjectId in found_ids] 

891 

892 if not objects: 

893 return 0 

894 

895 values = {validity_end_column: validityEnd_value} 

896 update = ( 

897 table.update() 

898 .where( 

899 sqlalchemy.and_( 

900 table.columns["diaObjectId"].in_(sorted(found_ids)), 

901 table.columns[validity_end_column].is_(None), 

902 ) 

903 ) 

904 .values(**values) 

905 ) 

906 result = conn.execute(update) 

907 if result.rowcount != len(found_ids): 

908 raise RuntimeError( 

909 f"Unexpected mismatch in the number of records updated. Object IDs = {found_ids}" 

910 ) 

911 

912 # Also drop them from DiaObjectLast. 

913 if self.config.dia_object_index == "last_object_table": 

914 last_table = self._schema.get_table(ApdbTables.DiaObjectLast) 

915 delete = last_table.delete().where(last_table.columns["diaObjectId"].in_(sorted(found_ids))) 

916 result = conn.execute(delete) 

917 if result.rowcount != len(found_ids): 

918 raise RuntimeError( 

919 f"Unexpected mismatch in the number of records deleted. Object IDs = {found_ids}" 

920 ) 

921 

922 # If replication is enabled then send all updates. 

923 if self._schema.replication_enabled: 

924 current_time = self._current_time() 

925 current_time_ns = int(current_time.unix_tai * 1e9) 

926 replica_chunk = ReplicaChunk.make_replica_chunk(current_time, self.config.replica_chunk_seconds) 

927 

928 update_records = [ 

929 ApdbCloseDiaObjectValidityRecord( 

930 diaObjectId=obj.diaObjectId, 

931 ra=obj.ra, 

932 dec=obj.dec, 

933 update_time_ns=current_time_ns, 

934 update_order=index, 

935 validityEndMjdTai=float(validityEnd.tai.mjd), 

936 nDiaSources=None, 

937 ) 

938 for index, obj in enumerate(objects) 

939 ] 

940 

941 self._storeUpdateRecords(update_records, replica_chunk, store_chunk=True) 

942 

943 return len(objects) 

944 

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

946 # docstring is inherited from a base class 

947 

948 # SQL backend does not have separate dedup tables, nothing to delete, 

949 # only save last dedup time in metadata. 

950 if dedup_time is None: 

951 dedup_time = self._current_time() 

952 data = {"dedup_time_iso_tai": dedup_time.tai.to_value("iso")} 

953 data_json = json.dumps(data) 

954 self._metadata.set(self.metadataDedupKey, data_json, force=True) 

955 

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

957 # docstring is inherited from a base class 

958 

959 timestamp: float | datetime.datetime 

960 now = self._current_time() 

961 timestamp_column = self._timestamp_column_name("ssObjectReassocTime") 

962 timestamp = self._timestamp_column_value(now) 

963 

964 table = self._schema.get_table(ApdbTables.DiaSource) 

965 query = table.update().where(table.columns["diaSourceId"] == sql.bindparam("srcId")) 

966 

967 with self._engine.begin() as conn: 

968 # Need to make sure that every ID exists in the database, but 

969 # executemany may not support rowcount, so iterate and check what 

970 # is missing. 

971 missing_ids: list[int] = [] 

972 for key, value in idMap.items(): 

973 params = { 

974 "srcId": key, 

975 "diaObjectId": 0, 

976 "ssObjectId": value, 

977 timestamp_column: timestamp, 

978 } 

979 result = conn.execute(query, params) 

980 if result.rowcount == 0: 

981 missing_ids.append(key) 

982 if missing_ids: 

983 missing = ",".join(str(item) for item in missing_ids) 

984 raise ValueError(f"Following DiaSource IDs do not exist in the database: {missing}") 

985 

986 def countUnassociatedObjects(self) -> int: 

987 # docstring is inherited from a base class 

988 

989 # Retrieve the DiaObject table. 

990 table: sqlalchemy.schema.Table = self._schema.get_table(ApdbTables.DiaObject) 

991 

992 # Construct the sql statement. 

993 validity_end_column = self._timestamp_column_name("validityEnd") 

994 stmt = ( 

995 sql.select(func.count()) 

996 .select_from(table) 

997 .where( 

998 sqlalchemy.and_( 

999 table.columns["nDiaSources"] == 1, 

1000 table.columns[validity_end_column].is_(None), 

1001 ) 

1002 ) 

1003 ) 

1004 

1005 # Return the count. 

1006 with self._engine.begin() as conn: 

1007 count = conn.execute(stmt).scalar_one() 

1008 

1009 return count 

1010 

1011 @property 

1012 def schema(self) -> ApdbSchema: 

1013 # docstring is inherited from a base class 

1014 return self._table_schema 

1015 

1016 @property 

1017 def metadata(self) -> ApdbMetadata: 

1018 # docstring is inherited from a base class 

1019 return self._metadata 

1020 

1021 @property 

1022 def admin(self) -> ApdbSqlAdmin: 

1023 # docstring is inherited from a base class 

1024 return ApdbSqlAdmin(self.pixelator) 

1025 

1026 def _getDiaSourcesInRegion(self, region: Region, start_time_mjdTai: float) -> pandas.DataFrame: 

1027 """Return catalog of DiaSource instances from given region. 

1028 

1029 Parameters 

1030 ---------- 

1031 region : `lsst.sphgeom.Region` 

1032 Region to search for DIASources. 

1033 start_time_mjdTai : `float` 

1034 Lower bound of time window for the query. 

1035 

1036 Returns 

1037 ------- 

1038 catalog : `pandas.DataFrame` 

1039 Catalog containing DiaSource records. 

1040 """ 

1041 table = self._schema.get_table(ApdbTables.DiaSource) 

1042 columns = self._schema.get_apdb_columns(ApdbTables.DiaSource) 

1043 query = sql.select(*columns) 

1044 

1045 # build selection 

1046 time_filter = table.columns["midpointMjdTai"] > start_time_mjdTai 

1047 where = sql.expression.and_(self._filterRegion(table, region), time_filter) 

1048 query = query.where(where) 

1049 

1050 # execute select 

1051 with self._timer("DiaSource_select_time", tags={"table": "DiaSource"}) as timer: 

1052 with self._engine.begin() as conn: 

1053 sources = pandas.read_sql_query(query, conn) 

1054 timer.add_values(row_counts=len(sources)) 

1055 _LOG.debug("found %s DiaSources", len(sources)) 

1056 return self._fix_result_timestamps(sources) 

1057 

1058 def _getDiaSourcesByIDs(self, object_ids: list[int], start_time_mjdTai: float) -> pandas.DataFrame: 

1059 """Return catalog of DiaSource instances given set of DiaObject IDs. 

1060 

1061 Parameters 

1062 ---------- 

1063 object_ids : 

1064 Collection of DiaObject IDs 

1065 start_time_mjdTai : `float` 

1066 Lower bound of time window for the query. 

1067 

1068 Returns 

1069 ------- 

1070 catalog : `pandas.DataFrame` 

1071 Catalog containing DiaSource records. 

1072 """ 

1073 with self._timer("select_time", tags={"table": "DiaSource"}) as timer: 

1074 sources = self._getSourcesByIDs(ApdbTables.DiaSource, object_ids, start_time_mjdTai) 

1075 timer.add_values(row_count=len(sources)) 

1076 

1077 _LOG.debug("found %s DiaSources", len(sources)) 

1078 return sources 

1079 

1080 def _getSourcesByIDs( 

1081 self, table_enum: ApdbTables, object_ids: list[int], midpointMjdTai_start: float 

1082 ) -> pandas.DataFrame: 

1083 """Return catalog of DiaSource or DiaForcedSource instances given set 

1084 of DiaObject IDs. 

1085 

1086 Parameters 

1087 ---------- 

1088 table : `sqlalchemy.schema.Table` 

1089 Database table. 

1090 object_ids : 

1091 Collection of DiaObject IDs 

1092 midpointMjdTai_start : `float` 

1093 Earliest midpointMjdTai to retrieve. 

1094 

1095 Returns 

1096 ------- 

1097 catalog : `pandas.DataFrame` 

1098 Catalog contaning DiaSource records. `None` is returned if 

1099 ``read_sources_months`` configuration parameter is set to 0 or 

1100 when ``object_ids`` is empty. 

1101 """ 

1102 table = self._schema.get_table(table_enum) 

1103 columns = self._schema.get_apdb_columns(table_enum) 

1104 

1105 sources: pandas.DataFrame | None = None 

1106 if len(object_ids) <= 0: 

1107 _LOG.debug("ID list is empty, just fetch empty result") 

1108 query = sql.select(*columns).where(sql.literal(False)) 

1109 with self._engine.begin() as conn: 

1110 sources = pandas.read_sql_query(query, conn) 

1111 else: 

1112 data_frames: list[pandas.DataFrame] = [] 

1113 for ids in chunk_iterable(sorted(object_ids), 1000): 

1114 query = sql.select(*columns) 

1115 

1116 # Some types like np.int64 can cause issues with 

1117 # sqlalchemy, convert them to int. 

1118 int_ids = [int(oid) for oid in ids] 

1119 

1120 # select by object id 

1121 query = query.where( 

1122 sql.expression.and_( 

1123 table.columns["diaObjectId"].in_(int_ids), 

1124 table.columns["midpointMjdTai"] >= midpointMjdTai_start, 

1125 ) 

1126 ) 

1127 

1128 # execute select 

1129 with self._engine.begin() as conn: 

1130 data_frames.append(pandas.read_sql_query(query, conn)) 

1131 

1132 if len(data_frames) == 1: 

1133 sources = data_frames[0] 

1134 else: 

1135 sources = pandas.concat(data_frames) 

1136 assert sources is not None, "Catalog cannot be None" 

1137 return self._fix_result_timestamps(sources) 

1138 

1139 def _storeReplicaChunk( 

1140 self, 

1141 replica_chunk: ReplicaChunk, 

1142 connection: sqlalchemy.engine.Connection, 

1143 ) -> None: 

1144 # `visit_time.datetime` returns naive datetime, even though all astropy 

1145 # times are in UTC. Add UTC timezone to timestamp so that database 

1146 # can store a correct value. 

1147 dt = datetime.datetime.fromtimestamp(replica_chunk.last_update_time.unix_tai, tz=datetime.UTC) 

1148 

1149 table = self._schema.get_table(ExtraTables.ApdbReplicaChunks) 

1150 

1151 # We need UPSERT which is dialect-specific construct 

1152 values = {"last_update_time": dt, "unique_id": replica_chunk.unique_id} 

1153 row = {"apdb_replica_chunk": replica_chunk.id} | values 

1154 if connection.dialect.name == "sqlite": 

1155 insert_sqlite = sqlalchemy.dialects.sqlite.insert(table) 

1156 insert_sqlite = insert_sqlite.on_conflict_do_update(index_elements=table.primary_key, set_=values) 

1157 connection.execute(insert_sqlite, row) 

1158 elif connection.dialect.name == "postgresql": 

1159 insert_pg = sqlalchemy.dialects.postgresql.dml.insert(table) 

1160 insert_pg = insert_pg.on_conflict_do_update(constraint=table.primary_key, set_=values) 

1161 connection.execute(insert_pg, row) 

1162 else: 

1163 raise TypeError(f"Unsupported dialect {connection.dialect.name} for upsert.") 

1164 

1165 def _storeDiaObjects( 

1166 self, 

1167 objs: pandas.DataFrame, 

1168 visit_time: astropy.time.Time, 

1169 replica_chunk: ReplicaChunk | None, 

1170 connection: sqlalchemy.engine.Connection, 

1171 ) -> None: 

1172 """Store catalog of DiaObjects from current visit. 

1173 

1174 Parameters 

1175 ---------- 

1176 objs : `pandas.DataFrame` 

1177 Catalog with DiaObject records. 

1178 visit_time : `astropy.time.Time` 

1179 Time of the visit. 

1180 replica_chunk : `ReplicaChunk` 

1181 Insert identifier. 

1182 """ 

1183 if len(objs) == 0: 

1184 _LOG.debug("No objects to write to database.") 

1185 return 

1186 

1187 # Some types like np.int64 can cause issues with sqlalchemy, convert 

1188 # them to int. 

1189 ids = sorted(int(oid) for oid in objs["diaObjectId"]) 

1190 _LOG.debug("first object ID: %d", ids[0]) 

1191 

1192 validity_start_column = self._timestamp_column_name("validityStart") 

1193 validity_end_column = self._timestamp_column_name("validityEnd") 

1194 timestamp = self._timestamp_column_value(visit_time) 

1195 

1196 # everything to be done in single transaction 

1197 if self.config.dia_object_index == "last_object_table": 

1198 # Insert and replace all records in LAST table. 

1199 table = self._schema.get_table(ApdbTables.DiaObjectLast) 

1200 

1201 # DiaObjectLast did not have this column in the past. 

1202 use_validity_start = self._schema.check_column(ApdbTables.DiaObjectLast, validity_start_column) 

1203 

1204 # Drop the previous objects (pandas cannot upsert). 

1205 query = table.delete().where(table.columns["diaObjectId"].in_(ids)) 

1206 

1207 with self._timer("delete_time", tags={"table": table.name}) as timer: 

1208 res = connection.execute(query) 

1209 timer.add_values(row_count=res.rowcount) 

1210 _LOG.debug("deleted %s objects", res.rowcount) 

1211 

1212 # DiaObjectLast is a subset of DiaObject, strip missing columns 

1213 last_column_names = [column.name for column in table.columns] 

1214 if validity_start_column in last_column_names and validity_start_column not in objs.columns: 

1215 last_column_names.remove(validity_start_column) 

1216 last_objs = objs[last_column_names] 

1217 last_objs = _coerce_uint64(last_objs) 

1218 

1219 # Fill validityStart, only when it is in the schema. 

1220 if use_validity_start: 

1221 if validity_start_column in last_objs: 

1222 last_objs[validity_start_column] = timestamp 

1223 else: 

1224 extra_column = pandas.Series([timestamp] * len(last_objs), name=validity_start_column) 

1225 last_objs.set_index(extra_column.index, inplace=True) 

1226 last_objs = pandas.concat([last_objs, extra_column], axis="columns") 

1227 

1228 with self._timer("insert_time", tags={"table": "DiaObjectLast"}) as timer: 

1229 last_objs.to_sql( 

1230 table.name, 

1231 connection, 

1232 if_exists="append", 

1233 index=False, 

1234 schema=table.schema, 

1235 ) 

1236 timer.add_values(row_count=len(last_objs)) 

1237 else: 

1238 # truncate existing validity intervals 

1239 table = self._schema.get_table(ApdbTables.DiaObject) 

1240 

1241 update = ( 

1242 table.update() 

1243 .values(**{validity_end_column: timestamp}) 

1244 .where( 

1245 sql.expression.and_( 

1246 table.columns["diaObjectId"].in_(ids), 

1247 table.columns[validity_end_column].is_(None), 

1248 ) 

1249 ) 

1250 ) 

1251 

1252 with self._timer("truncate_time", tags={"table": table.name}) as timer: 

1253 res = connection.execute(update) 

1254 timer.add_values(row_count=res.rowcount) 

1255 _LOG.debug("truncated %s intervals", res.rowcount) 

1256 

1257 objs = _coerce_uint64(objs) 

1258 

1259 # Fill additional columns 

1260 extra_columns: list[pandas.Series] = [] 

1261 if validity_start_column in objs.columns: 

1262 objs[validity_start_column] = timestamp 

1263 else: 

1264 extra_columns.append(pandas.Series([timestamp] * len(objs), name=validity_start_column)) 

1265 if validity_end_column in objs.columns: 

1266 objs[validity_end_column] = None 

1267 else: 

1268 extra_columns.append(pandas.Series([None] * len(objs), name=validity_end_column)) 

1269 if extra_columns: 

1270 objs.set_index(extra_columns[0].index, inplace=True) 

1271 objs = pandas.concat([objs] + extra_columns, axis="columns") 

1272 

1273 # Insert replica data 

1274 table = self._schema.get_table(ApdbTables.DiaObject) 

1275 replica_data: list[dict] = [] 

1276 replica_stmt: Any = None 

1277 replica_table_name = "" 

1278 if replica_chunk is not None: 

1279 pk_names = [column.name for column in table.primary_key] 

1280 replica_data = objs[pk_names].to_dict("records") 

1281 if replica_data: 

1282 for row in replica_data: 

1283 row["apdb_replica_chunk"] = replica_chunk.id 

1284 replica_table = self._schema.get_table(ExtraTables.DiaObjectChunks) 

1285 replica_table_name = replica_table.name 

1286 replica_stmt = replica_table.insert() 

1287 

1288 # insert new versions 

1289 with self._timer("insert_time", tags={"table": table.name}) as timer: 

1290 objs.to_sql(table.name, connection, if_exists="append", index=False, schema=table.schema) 

1291 timer.add_values(row_count=len(objs)) 

1292 if replica_stmt is not None: 

1293 with self._timer("insert_time", tags={"table": replica_table_name}) as timer: 

1294 connection.execute(replica_stmt, replica_data) 

1295 timer.add_values(row_count=len(replica_data)) 

1296 

1297 def _storeDiaSources( 

1298 self, 

1299 sources: pandas.DataFrame, 

1300 replica_chunk: ReplicaChunk | None, 

1301 connection: sqlalchemy.engine.Connection, 

1302 ) -> None: 

1303 """Store catalog of DiaSources from current visit. 

1304 

1305 Parameters 

1306 ---------- 

1307 sources : `pandas.DataFrame` 

1308 Catalog containing DiaSource records 

1309 """ 

1310 table = self._schema.get_table(ApdbTables.DiaSource) 

1311 

1312 # Insert replica data 

1313 replica_data: list[dict] = [] 

1314 replica_stmt: Any = None 

1315 replica_table_name = "" 

1316 if replica_chunk is not None: 

1317 pk_names = [column.name for column in table.primary_key] 

1318 replica_data = sources[pk_names].to_dict("records") 

1319 if replica_data: 

1320 for row in replica_data: 

1321 row["apdb_replica_chunk"] = replica_chunk.id 

1322 replica_table = self._schema.get_table(ExtraTables.DiaSourceChunks) 

1323 replica_table_name = replica_table.name 

1324 replica_stmt = replica_table.insert() 

1325 

1326 # everything to be done in single transaction 

1327 with self._timer("insert_time", tags={"table": table.name}) as timer: 

1328 sources = _coerce_uint64(sources) 

1329 sources.to_sql(table.name, connection, if_exists="append", index=False, schema=table.schema) 

1330 timer.add_values(row_count=len(sources)) 

1331 if replica_stmt is not None: 

1332 with self._timer("replica_insert_time", tags={"table": replica_table_name}) as timer: 

1333 connection.execute(replica_stmt, replica_data) 

1334 timer.add_values(row_count=len(replica_data)) 

1335 

1336 def _storeDiaForcedSources( 

1337 self, 

1338 sources: pandas.DataFrame, 

1339 replica_chunk: ReplicaChunk | None, 

1340 connection: sqlalchemy.engine.Connection, 

1341 ) -> None: 

1342 """Store a set of DiaForcedSources from current visit. 

1343 

1344 Parameters 

1345 ---------- 

1346 sources : `pandas.DataFrame` 

1347 Catalog containing DiaForcedSource records 

1348 """ 

1349 table = self._schema.get_table(ApdbTables.DiaForcedSource) 

1350 

1351 # Insert replica data 

1352 replica_data: list[dict] = [] 

1353 replica_stmt: Any = None 

1354 replica_table_name = "" 

1355 if replica_chunk is not None: 

1356 pk_names = [column.name for column in table.primary_key] 

1357 replica_data = sources[pk_names].to_dict("records") 

1358 if replica_data: 

1359 for row in replica_data: 

1360 row["apdb_replica_chunk"] = replica_chunk.id 

1361 replica_table = self._schema.get_table(ExtraTables.DiaForcedSourceChunks) 

1362 replica_table_name = replica_table.name 

1363 replica_stmt = replica_table.insert() 

1364 

1365 # everything to be done in single transaction 

1366 with self._timer("insert_time", tags={"table": table.name}) as timer: 

1367 sources = _coerce_uint64(sources) 

1368 sources.to_sql(table.name, connection, if_exists="append", index=False, schema=table.schema) 

1369 timer.add_values(row_count=len(sources)) 

1370 if replica_stmt is not None: 

1371 with self._timer("insert_time", tags={"table": replica_table_name}) as timer: 

1372 connection.execute(replica_stmt, replica_data) 

1373 timer.add_values(row_count=len(replica_data)) 

1374 

1375 def _storeUpdateRecords( 

1376 self, 

1377 records: Iterable[ApdbUpdateRecord], 

1378 chunk: ReplicaChunk, 

1379 *, 

1380 store_chunk: bool = False, 

1381 connection: sqlalchemy.engine.Connection | None = None, 

1382 ) -> None: 

1383 """Store ApdbUpdateRecords in the replica table for those records. 

1384 

1385 Parameters 

1386 ---------- 

1387 records : `list` [`ApdbUpdateRecord`] 

1388 Records to store. 

1389 chunk : `ReplicaChunk` 

1390 Replica chunk for these records. 

1391 store_chunk : `bool` 

1392 If True then also store replica chunk. 

1393 connection : `sqlalchemy.engine.Connection` 

1394 SQLALchemy connection to use, if `None` the new connection will be 

1395 made. `None` is useful for tests only, regular use will call this 

1396 method in the same transaction that saves other types of records. 

1397 

1398 Raises 

1399 ------ 

1400 TypeError 

1401 Raised if replication is not enabled for this instance. 

1402 """ 

1403 if not self._schema.replication_enabled: 

1404 raise TypeError("Replication is not enabled for this APDB instance.") 

1405 

1406 apdb_replica_chunk = chunk.id 

1407 # Do not use unique_if from ReplicaChunk as it could be reused in 

1408 # multiple calls to this method. 

1409 update_unique_id = uuid.uuid4() 

1410 

1411 record_dicts = [] 

1412 for record in records: 

1413 record_dicts.append( 

1414 { 

1415 "apdb_replica_chunk": apdb_replica_chunk, 

1416 "update_time_ns": record.update_time_ns, 

1417 "update_order": record.update_order, 

1418 "update_unique_id": update_unique_id, 

1419 "update_payload": record.to_json(), 

1420 } 

1421 ) 

1422 

1423 if not record_dicts: 

1424 return 

1425 

1426 # TODO: Need to check that table exists. 

1427 table = self._schema.get_table(ExtraTables.ApdbUpdateRecordChunks) 

1428 

1429 def _do_store(connection: sqlalchemy.engine.Connection) -> None: 

1430 if store_chunk: 

1431 self._storeReplicaChunk(chunk, connection) 

1432 with self._timer("insert_time", tags={"table": table.name}) as timer: 

1433 connection.execute(table.insert(), record_dicts) 

1434 timer.add_values(row_count=len(record_dicts)) 

1435 

1436 if connection is None: 

1437 with self._engine.begin() as connection: 

1438 _do_store(connection) 

1439 else: 

1440 _do_store(connection) 

1441 

1442 def _htm_indices(self, region: Region) -> list[tuple[int, int]]: 

1443 """Generate a set of HTM indices covering specified region. 

1444 

1445 Parameters 

1446 ---------- 

1447 region: `sphgeom.Region` 

1448 Region that needs to be indexed. 

1449 

1450 Returns 

1451 ------- 

1452 Sequence of ranges, range is a tuple (minHtmID, maxHtmID). 

1453 """ 

1454 _LOG.debug("region: %s", region) 

1455 indices = self.pixelator.envelope(region, self.config.pixelization.htm_max_ranges) 

1456 

1457 return indices.ranges() 

1458 

1459 def _filterRegion(self, table: sqlalchemy.schema.Table, region: Region) -> sql.ColumnElement: 

1460 """Make SQLAlchemy expression for selecting records in a region.""" 

1461 htm_index_column = table.columns[self.config.pixelization.htm_index_column] 

1462 exprlist = [] 

1463 pixel_ranges = self._htm_indices(region) 

1464 for low, upper in pixel_ranges: 

1465 upper -= 1 

1466 if low == upper: 

1467 exprlist.append(htm_index_column == low) 

1468 else: 

1469 exprlist.append(sql.expression.between(htm_index_column, low, upper)) 

1470 

1471 return sql.expression.or_(*exprlist) 

1472 

1473 def _add_spatial_index(self, df: pandas.DataFrame) -> pandas.DataFrame: 

1474 """Calculate spatial index for each record and add it to a DataFrame. 

1475 

1476 Parameters 

1477 ---------- 

1478 df : `pandas.DataFrame` 

1479 DataFrame which has to contain ra/dec columns, names of these 

1480 columns are defined by configuration ``ra_dec_columns`` field. 

1481 

1482 Returns 

1483 ------- 

1484 df : `pandas.DataFrame` 

1485 DataFrame with ``pixelId`` column which contains pixel index 

1486 for ra/dec coordinates. 

1487 

1488 Notes 

1489 ----- 

1490 This overrides any existing column in a DataFrame with the same name 

1491 (pixelId). Original DataFrame is not changed, copy of a DataFrame is 

1492 returned. 

1493 """ 

1494 # calculate HTM index for every DiaObject 

1495 htm_index = np.zeros(df.shape[0], dtype=np.int64) 

1496 ra_col, dec_col = self.config.ra_dec_columns 

1497 for i, (ra, dec) in enumerate(zip(df[ra_col], df[dec_col])): 

1498 uv3d = UnitVector3d(LonLat.fromDegrees(ra, dec)) 

1499 idx = self.pixelator.index(uv3d) 

1500 htm_index[i] = idx 

1501 df = df.copy() 

1502 df[self.config.pixelization.htm_index_column] = htm_index 

1503 return df 

1504 

1505 def _fix_input_timestamps(self, df: pandas.DataFrame) -> pandas.DataFrame: 

1506 """Update timestamp columns in input DataFrame to be aware datetime 

1507 type in in UTC. 

1508 

1509 AP pipeline generates naive datetime instances, we want them to be 

1510 aware before they go to database. All naive timestamps are assumed to 

1511 be in UTC timezone (they should be TAI). 

1512 """ 

1513 # Find all columns with aware non-UTC timestamps and convert to UTC. 

1514 columns = [ 

1515 column 

1516 for column, dtype in df.dtypes.items() 

1517 if isinstance(dtype, pandas.DatetimeTZDtype) and dtype.tz is not datetime.UTC 

1518 ] 

1519 for column in columns: 

1520 df[column] = df[column].dt.tz_convert(datetime.UTC) 

1521 # Find all columns with naive timestamps and add UTC timezone. 

1522 columns = [ 

1523 column for column, dtype in df.dtypes.items() if pandas.api.types.is_datetime64_dtype(dtype) 

1524 ] 

1525 for column in columns: 

1526 df[column] = df[column].dt.tz_localize(datetime.UTC) 

1527 return df 

1528 

1529 def _fix_result_timestamps(self, df: pandas.DataFrame) -> pandas.DataFrame: 

1530 """Update timestamp columns to be naive datetime type in returned 

1531 DataFrame. 

1532 

1533 AP pipeline code expects DataFrames to contain naive datetime columns, 

1534 while Postgres queries return timezone-aware type. This method converts 

1535 those columns to naive datetime in UTC timezone. 

1536 """ 

1537 # Find all columns with aware timestamps. 

1538 columns = [column for column, dtype in df.dtypes.items() if isinstance(dtype, pandas.DatetimeTZDtype)] 

1539 for column in columns: 

1540 # tz_convert(None) will convert to UTC and drop timezone. 

1541 df[column] = df[column].dt.tz_convert(None) 

1542 return df 

1543 

1544 def _timestamp_column_name(self, column: str) -> str: 

1545 """Return column name before/after schema migration to MJD TAI.""" 

1546 return self.schema.timestamp_column_name(column) 

1547 

1548 def _timestamp_column_value(self, time: astropy.time.Time) -> float | datetime.datetime: 

1549 """Return column value before/after schema migration to MJD TAI.""" 

1550 if self.schema.has_mjd_timestamps: 

1551 return float(time.tai.mjd) 

1552 else: 

1553 return time.datetime.astimezone(tz=datetime.UTC) 

1554 

1555 def _get_diaobject_data( 

1556 self, conn: sqlalchemy.engine.Connection, object_ids: Iterable[int], *columns: str 

1557 ) -> list: 

1558 """Select records from either DiaObject or DiaObjectLast and return 

1559 selected rows as names tuples. 

1560 """ 

1561 where: sqlalchemy.ColumnElement[bool] 

1562 if self.config.dia_object_index == "last_object_table": 

1563 table = self._schema.get_table(ApdbTables.DiaObjectLast) 

1564 where = table.columns["diaObjectId"].in_(sorted(object_ids)) 

1565 else: 

1566 table = self._schema.get_table(ApdbTables.DiaObject) 

1567 validity_end_column = self._timestamp_column_name("validityEnd") 

1568 where = sqlalchemy.and_( 

1569 table.columns["diaObjectId"].in_(sorted(object_ids)), 

1570 table.columns[validity_end_column].is_(None), 

1571 ) 

1572 column_list = [table.columns["diaObjectId"]] + [table.columns[column] for column in columns] 

1573 query = sql.select(*column_list).where(where) 

1574 result = conn.execute(query) 

1575 

1576 return list(result) 

1577 

1578 def _get_diasource_data( 

1579 self, conn: sqlalchemy.engine.Connection, source_ids: Iterable[int], *columns: str 

1580 ) -> list: 

1581 """Select records from DiaSource table by diaSourceId and return 

1582 selected rows as named tuples. 

1583 """ 

1584 table = self._schema.get_table(ApdbTables.DiaSource) 

1585 where = table.columns["diaSourceId"].in_(sorted(source_ids)) 

1586 column_list = [table.columns["diaSourceId"]] + [table.columns[column] for column in columns] 

1587 query = sql.select(*column_list).where(where) 

1588 result = conn.execute(query) 

1589 

1590 return list(result)