Coverage for python/lsst/daf/butler/registry/obscore/_manager.py: 17%

179 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 01:33 -0800

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 software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28from __future__ import annotations 

29 

30__all__ = ["ObsCoreLiveTableManager"] 

31 

32import re 

33import warnings 

34from collections import defaultdict 

35from collections.abc import Collection, Iterable, Iterator, Mapping 

36from contextlib import AbstractContextManager, contextmanager 

37from typing import TYPE_CHECKING, Any 

38 

39import sqlalchemy 

40 

41from lsst.daf.butler import Config, DataCoordinate, DatasetRef, DimensionUniverse 

42from lsst.sphgeom import Region 

43from lsst.utils.introspection import find_outside_stacklevel 

44from lsst.utils.iteration import chunk_iterable 

45 

46from ..._column_type_info import ColumnTypeInfo 

47from ...queries import Query, QueryFactoryFunction 

48from ..interfaces import ObsCoreTableManager, VersionTuple 

49from ._config import ConfigCollectionType, ObsCoreManagerConfig 

50from ._records import DerivedRegionFactory, Record, RecordFactory 

51from ._schema import ObsCoreSchema 

52from ._spatial import RegionTypeError, RegionTypeWarning, SpatialObsCorePlugin 

53 

54if TYPE_CHECKING: 

55 from ..interfaces import ( 

56 CollectionRecord, 

57 Database, 

58 DatasetRecordStorageManager, 

59 DimensionRecordStorageManager, 

60 StaticTablesContext, 

61 ) 

62 

63_VERSION = VersionTuple(0, 0, 1) 

64 

65 

66class _ExposureRegionFactory(DerivedRegionFactory): 

67 """Find exposure region from a matching visit dimensions records. 

68 

69 Parameters 

70 ---------- 

71 dimensions : `DimensionRecordStorageManager` 

72 The dimension records storage manager. 

73 query_func : `QueryFactoryFunction` 

74 Function returning a context manager that sets up a `Query` object 

75 for querying the registry. (That is, a function equivalent to 

76 ``Butler.query()``). 

77 """ 

78 

79 def __init__( 

80 self, 

81 dimensions: DimensionRecordStorageManager, 

82 query_func: QueryFactoryFunction, 

83 ): 

84 self.dimensions = dimensions 

85 self.universe = dimensions.universe 

86 self.exposure_dimensions = self.universe["exposure"].minimal_group 

87 self.exposure_detector_dimensions = self.universe.conform(["exposure", "detector"]) 

88 self._query_func = query_func 

89 

90 def derived_region(self, dataId: DataCoordinate) -> Region | None: 

91 # Docstring is inherited from a base class. 

92 

93 # Make sure the dimension universe contains a table that can be used 

94 # to find visits from exposures. 

95 if "visit_definition" not in self.universe.elements.names: 

96 return None 

97 # Choose the table we will use to look up the visit region: 

98 # either visit+detector regions or visit regions. 

99 if "detector" in dataId.dimensions: 

100 if "visit_detector_region" not in self.universe: 

101 return None 

102 constraint_data_id = dataId.subset(self.exposure_detector_dimensions) 

103 region_dimension = "visit_detector_region" 

104 else: 

105 if "visit" not in self.universe: 

106 return None 

107 constraint_data_id = dataId.subset(self.exposure_dimensions) 

108 region_dimension = "visit" 

109 

110 with self._query_func() as query: 

111 result = list( 

112 query.dimension_records(region_dimension) 

113 # Constrain the relation to match the given exposure and (if 

114 # present) detector IDs. 

115 .where(constraint_data_id) 

116 # If we get more than one result (because the exposure belongs 

117 # to multiple visits), just pick an arbitrary one. 

118 .limit(1) 

119 ) 

120 if len(result) > 0: 

121 return result[0].region 

122 else: 

123 return None 

124 

125 

126class ObsCoreLiveTableManager(ObsCoreTableManager): 

127 """A manager class for ObsCore table, implements methods for updating the 

128 records in that table. 

129 

130 Parameters 

131 ---------- 

132 db : `Database` 

133 The active database. 

134 table : `sqlalchemy.schema.Table` 

135 The ObsCore table. 

136 schema : `ObsCoreSchema` 

137 The relevant schema. 

138 universe : `DimensionUniverse` 

139 The dimension universe. 

140 config : `ObsCoreManagerConfig` 

141 The config controlling the manager. 

142 dimensions : `DimensionRecordStorageManager` 

143 The storage manager for the dimension records. 

144 spatial_plugins : `~collections.abc.Collection` of `SpatialObsCorePlugin` 

145 Spatial plugins. 

146 registry_schema_version : `VersionTuple` or `None`, optional 

147 Version of registry schema. 

148 column_type_info : `ColumnTypeInfo` 

149 Information about column types that can differ between data 

150 repositories and registry instances. 

151 """ 

152 

153 def __init__( 

154 self, 

155 *, 

156 db: Database, 

157 table: sqlalchemy.schema.Table, 

158 schema: ObsCoreSchema, 

159 universe: DimensionUniverse, 

160 config: ObsCoreManagerConfig, 

161 dimensions: DimensionRecordStorageManager, 

162 spatial_plugins: Collection[SpatialObsCorePlugin], 

163 registry_schema_version: VersionTuple | None = None, 

164 column_type_info: ColumnTypeInfo, 

165 ): 

166 super().__init__(registry_schema_version=registry_schema_version) 

167 self.db = db 

168 self.table = table 

169 self.schema = schema 

170 self.universe = universe 

171 self.config = config 

172 self.spatial_plugins = spatial_plugins 

173 self._column_type_info = column_type_info 

174 self._query_func: QueryFactoryFunction | None = None 

175 exposure_region_factory = _ExposureRegionFactory(dimensions, self._get_query_object) 

176 self.record_factory = RecordFactory.get_record_type_from_universe(universe)( 

177 config, schema, universe, spatial_plugins, exposure_region_factory 

178 ) 

179 self.tagged_collection: str | None = None 

180 self.run_patterns: list[re.Pattern] = [] 

181 if config.collection_type is ConfigCollectionType.TAGGED: 

182 assert config.collections is not None and len(config.collections) == 1, ( 

183 "Exactly one collection name required for tagged type." 

184 ) 

185 self.tagged_collection = config.collections[0] 

186 elif config.collection_type is ConfigCollectionType.RUN: 

187 if config.collections: 

188 for coll in config.collections: 

189 try: 

190 self.run_patterns.append(re.compile(coll)) 

191 except re.error as exc: 

192 raise ValueError(f"Failed to compile regex: {coll!r}") from exc 

193 else: 

194 raise ValueError(f"Unexpected value of collection_type: {config.collection_type}") 

195 

196 def clone(self, *, db: Database, dimensions: DimensionRecordStorageManager) -> ObsCoreLiveTableManager: 

197 manager = ObsCoreLiveTableManager( 

198 db=db, 

199 table=self.table, 

200 schema=self.schema, 

201 universe=self.universe, 

202 config=self.config, 

203 dimensions=dimensions, 

204 # Current spatial plugins are safe to share without cloning -- they 

205 # are immutable and do not use their Database object outside of 

206 # 'initialize'. 

207 spatial_plugins=self.spatial_plugins, 

208 registry_schema_version=self._registry_schema_version, 

209 column_type_info=self._column_type_info, 

210 ) 

211 if self._query_func is not None: 

212 manager.set_query_function(self._query_func) 

213 return manager 

214 

215 @classmethod 

216 def initialize( 

217 cls, 

218 db: Database, 

219 context: StaticTablesContext, 

220 *, 

221 universe: DimensionUniverse, 

222 config: Mapping, 

223 datasets: type[DatasetRecordStorageManager], 

224 dimensions: DimensionRecordStorageManager, 

225 registry_schema_version: VersionTuple | None = None, 

226 column_type_info: ColumnTypeInfo, 

227 ) -> ObsCoreTableManager: 

228 # Docstring inherited from base class. 

229 config_data = Config(config) 

230 obscore_config = ObsCoreManagerConfig.model_validate(config_data) 

231 

232 # Instantiate all spatial plugins. 

233 spatial_plugins = SpatialObsCorePlugin.load_plugins(obscore_config.spatial_plugins, db) 

234 

235 schema = ObsCoreSchema(config=obscore_config, spatial_plugins=spatial_plugins, datasets=datasets) 

236 

237 # Generate table specification for main obscore table. 

238 table_spec = schema.table_spec 

239 for plugin in spatial_plugins: 

240 plugin.extend_table_spec(table_spec) 

241 table = context.addTable(obscore_config.table_name, schema.table_spec) 

242 

243 return ObsCoreLiveTableManager( 

244 db=db, 

245 table=table, 

246 schema=schema, 

247 universe=universe, 

248 config=obscore_config, 

249 dimensions=dimensions, 

250 spatial_plugins=spatial_plugins, 

251 registry_schema_version=registry_schema_version, 

252 column_type_info=column_type_info, 

253 ) 

254 

255 def _get_query_object(self) -> AbstractContextManager[Query]: 

256 if self._query_func is None: 

257 raise AssertionError("set_query_function should have been called prior to this.") 

258 

259 return self._query_func() 

260 

261 def set_query_function(self, query_func: QueryFactoryFunction) -> None: 

262 self._query_func = query_func 

263 

264 def config_json(self) -> str: 

265 """Dump configuration in JSON format. 

266 

267 Returns 

268 ------- 

269 json : `str` 

270 Configuration serialized in JSON format. 

271 """ 

272 return self.config.model_dump_json() 

273 

274 @classmethod 

275 def currentVersions(cls) -> list[VersionTuple]: 

276 # Docstring inherited from base class. 

277 return [_VERSION] 

278 

279 def add_datasets(self, refs: Iterable[DatasetRef]) -> int: 

280 # Docstring inherited from base class. 

281 

282 # Only makes sense for RUN collection types 

283 if self.config.collection_type is not ConfigCollectionType.RUN: 

284 return 0 

285 

286 obscore_refs: Iterable[DatasetRef] 

287 if self.run_patterns: 

288 # Check each dataset run against configured run list. We want to 

289 # reduce number of calls to _check_dataset_run, which may be 

290 # expensive. Normally references are grouped by run, if there are 

291 # multiple input references, they should have the same run. 

292 # Instead of just checking that, we group them by run again. 

293 refs_by_run: dict[str, list[DatasetRef]] = defaultdict(list) 

294 for ref in refs: 

295 # Record factory will filter dataset types, but to reduce 

296 # collection checks we also pre-filter it here. 

297 if ref.datasetType.name not in self.config.dataset_types: 

298 continue 

299 

300 assert ref.run is not None, "Run cannot be None" 

301 refs_by_run[ref.run].append(ref) 

302 

303 good_refs: list[DatasetRef] = [] 

304 for run, run_refs in refs_by_run.items(): 

305 if not self._check_dataset_run(run): 

306 continue 

307 good_refs.extend(run_refs) 

308 obscore_refs = good_refs 

309 

310 else: 

311 # Take all refs, no collection check. 

312 obscore_refs = refs 

313 

314 return self._populate(obscore_refs) 

315 

316 def associate(self, refs: Iterable[DatasetRef], collection: CollectionRecord) -> int: 

317 # Docstring inherited from base class. 

318 

319 # Only works when collection type is TAGGED 

320 if self.tagged_collection is None: 

321 return 0 

322 

323 if collection.name == self.tagged_collection: 

324 return self._populate(refs) 

325 else: 

326 return 0 

327 

328 def disassociate(self, refs: Iterable[DatasetRef], collection: CollectionRecord) -> int: 

329 # Docstring inherited from base class. 

330 

331 # Only works when collection type is TAGGED 

332 if self.tagged_collection is None: 

333 return 0 

334 

335 count = 0 

336 if collection.name == self.tagged_collection: 

337 # Sorting may improve performance 

338 dataset_ids = sorted(ref.id for ref in refs) 

339 if dataset_ids: 

340 fk_field = self.schema.dataset_fk 

341 assert fk_field is not None, "Cannot be None by construction" 

342 # There may be too many of them, do it in chunks. 

343 for ids in chunk_iterable(dataset_ids): 

344 where = self.table.columns[fk_field.name].in_(ids) 

345 count += self.db.deleteWhere(self.table, where) 

346 return count 

347 

348 def _populate(self, refs: Iterable[DatasetRef]) -> int: 

349 """Populate obscore table with the data from given datasets.""" 

350 records: list[Record] = [] 

351 for ref in refs: 

352 record = self.record_factory(ref) 

353 if record is not None: 

354 records.append(record) 

355 

356 if records: 

357 # Ignore potential conflicts with existing datasets. 

358 return self.db.ensure(self.table, *records, primary_key_only=True) 

359 else: 

360 return 0 

361 

362 def _check_dataset_run(self, run: str) -> bool: 

363 """Check that specified run collection matches know patterns.""" 

364 if not self.run_patterns: 

365 # Empty list means take anything. 

366 return True 

367 

368 # Try each pattern in turn. 

369 return any(pattern.fullmatch(run) for pattern in self.run_patterns) 

370 

371 def update_exposure_regions(self, instrument: str, region_data: Iterable[tuple[int, int, Region]]) -> int: 

372 # Docstring inherited from base class. 

373 instrument_column = self.schema.dimension_column("instrument") 

374 exposure_column = self.schema.dimension_column("exposure") 

375 detector_column = self.schema.dimension_column("detector") 

376 if instrument_column is None or exposure_column is None or detector_column is None: 

377 # Not all needed columns are in the table. 

378 return 0 

379 

380 update_rows: list[Record] = [] 

381 for exposure, detector, region in region_data: 

382 try: 

383 record = self.record_factory.make_spatial_records(region) 

384 except RegionTypeError as exc: 

385 warnings.warn( 

386 f"Failed to convert region for exposure={exposure} detector={detector}: {exc}", 

387 category=RegionTypeWarning, 

388 stacklevel=find_outside_stacklevel("lsst.daf.butler"), 

389 ) 

390 continue 

391 

392 record.update( 

393 { 

394 "instrument_column": instrument, 

395 "exposure_column": exposure, 

396 "detector_column": detector, 

397 } 

398 ) 

399 update_rows.append(record) 

400 

401 where_dict: dict[str, str] = { 

402 instrument_column: "instrument_column", 

403 exposure_column: "exposure_column", 

404 detector_column: "detector_column", 

405 } 

406 

407 count = self.db.update(self.table, where_dict, *update_rows) 

408 return count 

409 

410 @contextmanager 

411 def query( 

412 self, columns: Iterable[str | sqlalchemy.sql.expression.ColumnElement] | None = None, /, **kwargs: Any 

413 ) -> Iterator[sqlalchemy.engine.CursorResult]: 

414 # Docstring inherited from base class. 

415 if columns is not None: 

416 column_elements: list[sqlalchemy.sql.ColumnElement] = [] 

417 for column in columns: 

418 if isinstance(column, str): 

419 column_elements.append(self.table.columns[column]) 

420 else: 

421 column_elements.append(column) 

422 query = sqlalchemy.sql.select(*column_elements).select_from(self.table) 

423 else: 

424 query = self.table.select() 

425 

426 if kwargs: 

427 query = query.where( 

428 sqlalchemy.sql.expression.and_( 

429 *[self.table.columns[column] == value for column, value in kwargs.items()] 

430 ) 

431 ) 

432 with self.db.query(query) as result: 

433 yield result