Coverage for python / lsst / pipe / base / all_dimensions_quantum_graph_builder.py: 17%

537 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-10 01:40 -0800

1# This file is part of pipe_base. 

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 

28"""The standard, general-purpose implementation of the QuantumGraph-generation 

29algorithm. 

30""" 

31 

32from __future__ import annotations 

33 

34__all__ = ("AllDimensionsQuantumGraphBuilder", "DatasetQueryConstraintVariant") 

35 

36import dataclasses 

37import itertools 

38from collections import defaultdict 

39from collections.abc import Callable, Iterable, Mapping 

40from typing import TYPE_CHECKING, Any, final 

41 

42import astropy.table 

43 

44from lsst.daf.butler import ( 

45 Butler, 

46 DataCoordinate, 

47 DimensionElement, 

48 DimensionGroup, 

49 DimensionRecordSet, 

50 MissingDatasetTypeError, 

51 SkyPixDimension, 

52) 

53from lsst.sphgeom import RangeSet 

54from lsst.utils.logging import LsstLogAdapter, PeriodicLogger 

55from lsst.utils.timer import timeMethod 

56 

57from ._datasetQueryConstraints import DatasetQueryConstraintVariant 

58from .quantum_graph_builder import QuantumGraphBuilder, QuantumGraphBuilderError 

59from .quantum_graph_skeleton import DatasetKey, PrerequisiteDatasetKey, QuantumGraphSkeleton, QuantumKey 

60 

61if TYPE_CHECKING: 

62 from .pipeline_graph import DatasetTypeNode, PipelineGraph, TaskNode 

63 

64 

65@final 

66class AllDimensionsQuantumGraphBuilder(QuantumGraphBuilder): 

67 """An implementation of `.quantum_graph_builder.QuantumGraphBuilder` that 

68 uses a single large query for data IDs covering all dimensions in the 

69 pipeline. 

70 

71 Parameters 

72 ---------- 

73 pipeline_graph : `.pipeline_graph.PipelineGraph` 

74 Pipeline to build a `.QuantumGraph` from, as a graph. Will be resolved 

75 in-place with the given butler (any existing resolution is ignored). 

76 butler : `lsst.daf.butler.Butler` 

77 Client for the data repository. Should be read-only. 

78 where : `str`, optional 

79 Butler expression language constraint to apply to all data IDs. 

80 dataset_query_constraint : `DatasetQueryConstraintVariant`, optional 

81 Specification of which overall-input datasets should be used to 

82 constrain the initial data ID queries. Not including an important 

83 constraint can result in catastrophically large query results that take 

84 too long to process, while including too many makes the query much more 

85 complex, increasing the chances that the database will choose a bad 

86 (sometimes catastrophically bad) query plan. 

87 bind : `~collections.abc.Mapping`, optional 

88 Variable substitutions for the ``where`` expression. 

89 data_id_tables : `~collections.abc.Iterable` [ `astropy.table.Table` ],\ 

90 optional 

91 Tables of data IDs to join in as constraints. Missing dimensions that 

92 are constrained by the ``where`` argument or pipeline data ID will be 

93 filled in automatically. 

94 **kwargs 

95 Additional keyword arguments forwarded to 

96 `.quantum_graph_builder.QuantumGraphBuilder`. 

97 

98 Notes 

99 ----- 

100 This is a general-purpose algorithm that delegates the problem of 

101 determining which "end" of the pipeline is more constrained (beginning by 

102 input collection contents vs. end by the ``where`` string) to the database 

103 query planner, which *usually* does a good job. 

104 

105 This algorithm suffers from a serious limitation, which we refer to as the 

106 "tract slicing" problem from its most common variant: the ``where`` string 

107 and general data ID intersection rules apply to *all* data IDs in the 

108 graph. For example, if a ``tract`` constraint is present in the ``where`` 

109 string or an overall-input dataset, then it is impossible for any data ID 

110 that does not overlap that tract to be present anywhere in the pipeline, 

111 such as a ``{visit, detector}`` combination where the ``visit`` overlaps 

112 the ``tract`` even if the ``detector`` does not. 

113 """ 

114 

115 def __init__( 

116 self, 

117 pipeline_graph: PipelineGraph, 

118 butler: Butler, 

119 *, 

120 where: str = "", 

121 dataset_query_constraint: DatasetQueryConstraintVariant = DatasetQueryConstraintVariant.ALL, 

122 bind: Mapping[str, Any] | None = None, 

123 data_id_tables: Iterable[astropy.table.Table] = (), 

124 **kwargs: Any, 

125 ): 

126 super().__init__(pipeline_graph, butler, **kwargs) 

127 assert where is not None, "'where' should be an empty str, not None" 

128 self.where = where 

129 self.dataset_query_constraint = dataset_query_constraint 

130 self.bind = bind 

131 self.data_id_tables = list(data_id_tables) 

132 

133 @timeMethod 

134 def process_subgraph(self, subgraph: PipelineGraph) -> QuantumGraphSkeleton: 

135 # Docstring inherited. 

136 # There is some chance that the dimension query for one subgraph would 

137 # be the same as or a dimension-subset of another. This is an 

138 # optimization opportunity we're not currently taking advantage of. 

139 tree = _DimensionGroupTree(subgraph) 

140 tree.build(self.dataset_query_constraint, self.data_id_tables, log=self.log) 

141 tree.pprint(printer=self.log.debug) 

142 self._query_for_data_ids(tree) 

143 dimension_records = self._fetch_most_dimension_records(tree) 

144 tree.generate_data_ids(self.log) 

145 skeleton: QuantumGraphSkeleton = self._make_subgraph_skeleton(tree) 

146 if not skeleton.has_any_quanta: 

147 # QG is going to be empty; exit early not just for efficiency, but 

148 # also so downstream code doesn't have to guard against this case. 

149 return skeleton 

150 self._find_followup_datasets(tree, skeleton) 

151 all_data_id_dimensions = subgraph.get_all_dimensions() 

152 skeleton.attach_dimension_records(self.butler, all_data_id_dimensions, dimension_records) 

153 return skeleton 

154 

155 def _query_for_data_ids(self, tree: _DimensionGroupTree) -> None: 

156 """Query for data IDs and use the result to populate the dimension 

157 group tree. 

158 

159 Parameters 

160 ---------- 

161 tree : `_DimensionGroupTree` 

162 Tree with dimension group branches that holds subgraph-specific 

163 state for this builder, to be modified in place. 

164 """ 

165 query_cmd: list[str] = [] 

166 with self.butler.query() as query: 

167 query_cmd.append("with butler.query() as query:") 

168 query_cmd.append(f" query = query.join_dimensions({list(tree.queryable_dimensions.names)})") 

169 query = query.join_dimensions(tree.queryable_dimensions) 

170 if tree.dataset_constraint: 

171 query_cmd.append(f" collections = {list(self.input_collections)}") 

172 for dataset_type_name in tree.dataset_constraint: 

173 query_cmd.append(f" query = query.join_dataset_search({dataset_type_name!r}, collections)") 

174 try: 

175 query = query.join_dataset_search(dataset_type_name, self.input_collections) 

176 except MissingDatasetTypeError: 

177 raise QuantumGraphBuilderError( 

178 f"No datasets for overall-input {dataset_type_name!r} found (the dataset type is " 

179 "not even registered). This is probably a bug in either the pipeline definition or " 

180 "the dataset constraints passed to the quantum graph builder." 

181 ) from None 

182 query_cmd.append( 

183 f" query = query.where({dict(tree.subgraph.data_id.mapping)}, " 

184 f"{self.where!r}, bind={self.bind!r})" 

185 ) 

186 query = query.where(tree.subgraph.data_id, self.where, bind=self.bind) 

187 # It's important for tables to be joined in last, so data IDs from 

188 # pipeline and where can be used to fill in missing columns. 

189 for table in self.data_id_tables: 

190 # If this is from ctrl_mpexec's pipetask, it'll have added 

191 # a filename to the metadata for us. 

192 table_name = table.meta.get("filename", "unknown") 

193 query_cmd.append(f" query = query.join_data_coordinate_table(<{table_name}>)") 

194 query = query.join_data_coordinate_table(table) 

195 self.log.verbose("Querying for data IDs via: %s", "\n".join(query_cmd)) 

196 # Allow duplicates from common skypix overlaps to make some queries 

197 # run faster. 

198 query._allow_duplicate_overlaps = True 

199 # Iterate over query results, populating data IDs for datasets, 

200 # quanta, and edges. We populate only the first level of the tree 

201 # in the first pass, so we can be done with the query results as 

202 # quickly as possible in case that holds a connection/cursor open. 

203 n_rows = 0 

204 progress_logger: PeriodicLogger | None = None 

205 for common_data_id in query.data_ids(tree.queryable_dimensions): 

206 if progress_logger is None: 

207 # There can be a long wait between submitting the query and 

208 # returning the first row, so we want to make sure we log 

209 # when we get it; note that PeriodicLogger is not going to 

210 # do that for us, as it waits for its interval _after_ the 

211 # first log is seen. 

212 self.log.info("Iterating over data ID query results.") 

213 progress_logger = PeriodicLogger(self.log) 

214 for branch_dimensions, branch in tree.queryable_branches.items(): 

215 data_id = common_data_id.subset(branch_dimensions) 

216 branch.data_ids.add(data_id) 

217 n_rows += 1 

218 progress_logger.log("Iterating over data ID query results: %d rows processed so far.", n_rows) 

219 if n_rows == 0: 

220 # A single multiline log plays better with log aggregators like 

221 # Loki. 

222 lines = ["Initial data ID query returned no rows, so QuantumGraph will be empty."] 

223 try: 

224 lines.extend(query.explain_no_results()) 

225 finally: 

226 lines.append("To reproduce this query for debugging purposes, run:") 

227 lines.append("") 

228 lines.extend(query_cmd) 

229 lines.append(" print(query.any())") 

230 lines.append("") 

231 lines.append("And then try removing various constraints until query.any() returns True.") 

232 # If an exception was raised, write a partial. 

233 self.log.error("\n".join(lines)) 

234 return 

235 self.log.verbose("Done iterating over query results: %d rows processed in total.", n_rows) 

236 # We now recursively populate the data IDs of the rest of the tree. 

237 tree.project_data_ids(self.log) 

238 

239 @timeMethod 

240 def _make_subgraph_skeleton(self, tree: _DimensionGroupTree) -> QuantumGraphSkeleton: 

241 """Build a `QuantumGraphSkeleton` by processing the data IDs in the 

242 dimension group tree. 

243 

244 Parameters 

245 ---------- 

246 tree : `_DimensionGroupTree` 

247 Tree with dimension group branches that holds subgraph-specific 

248 state for this builder. 

249 

250 Returns 

251 ------- 

252 skeleton : `QuantumGraphSkeleton` 

253 Preliminary quantum graph. 

254 """ 

255 skeleton = QuantumGraphSkeleton(tree.subgraph.tasks) 

256 for branch_dimensions, branch in tree.branches_by_dimensions.items(): 

257 self.log.verbose( 

258 "Adding nodes for %s %s data ID(s).", 

259 len(branch.data_ids), 

260 branch_dimensions, 

261 ) 

262 branch.update_skeleton_nodes(skeleton) 

263 for branch_dimensions, branch in tree.branches_by_dimensions.items(): 

264 self.log.verbose( 

265 "Adding edges for %s %s data ID(s).", 

266 len(branch.data_ids), 

267 branch_dimensions, 

268 ) 

269 branch.update_skeleton_edges(skeleton) 

270 n_quanta = sum(len(skeleton.get_quanta(task_label)) for task_label in tree.subgraph.tasks) 

271 self.log.info( 

272 "Initial bipartite graph has %d quanta, %d dataset nodes, and %d edges.", 

273 n_quanta, 

274 skeleton.n_nodes - n_quanta, 

275 skeleton.n_edges, 

276 ) 

277 return skeleton 

278 

279 @timeMethod 

280 def _find_followup_datasets(self, tree: _DimensionGroupTree, skeleton: QuantumGraphSkeleton) -> None: 

281 """Populate `existing_datasets` by performing follow-up queries with 

282 the data IDs in the dimension group tree. 

283 

284 Parameters 

285 ---------- 

286 tree : `_DimensionGroupTree` 

287 Tree with dimension group branches that holds subgraph-specific 

288 state for this builder. 

289 skeleton : `.quantum_graph_skeleton.QuantumGraphSkeleton` 

290 In-progress quantum graph to modify in place. 

291 """ 

292 dataset_key: DatasetKey | PrerequisiteDatasetKey 

293 for dimensions, branch in tree.branches_by_dimensions.items(): 

294 if not dimensions: 

295 for dataset_type_name in branch.dataset_types.keys(): 

296 dataset_key = DatasetKey(dataset_type_name, self.empty_data_id.required_values) 

297 if ref := self.empty_dimensions_datasets.inputs.get(dataset_key): 

298 skeleton.set_dataset_ref(ref, dataset_key) 

299 if ref := self.empty_dimensions_datasets.outputs_for_skip.get(dataset_key): 

300 skeleton.set_output_for_skip(ref) 

301 if ref := self.empty_dimensions_datasets.outputs_in_the_way.get(dataset_key): 

302 skeleton.set_output_in_the_way(ref) 

303 continue 

304 if not branch.dataset_types and not branch.tasks: 

305 continue 

306 if not branch.data_ids: 

307 continue 

308 # Iterate over regular input/output dataset type nodes with these 

309 # dimensions to find those datasets using followup queries. 

310 with self.butler.query() as butler_query: 

311 butler_query = butler_query.join_data_coordinates(branch.data_ids) 

312 for dataset_type_node in branch.dataset_types.values(): 

313 if tree.subgraph.producer_of(dataset_type_node.name) is None: 

314 # Dataset type is an overall input; we always need to 

315 # try to find these. 

316 count = 0 

317 try: 

318 for ref in butler_query.datasets(dataset_type_node.name, self.input_collections): 

319 skeleton.set_dataset_ref(ref) 

320 count += 1 

321 except MissingDatasetTypeError: 

322 pass 

323 self.log.verbose( 

324 "Found %d overall-input dataset(s) of type %r.", count, dataset_type_node.name 

325 ) 

326 continue 

327 if self.skip_existing_in: 

328 # Dataset type is an intermediate or output; need to 

329 # find these if only they're from previously executed 

330 # quanta that we might skip... 

331 count = 0 

332 try: 

333 for ref in butler_query.datasets(dataset_type_node.name, self.skip_existing_in): 

334 skeleton.set_output_for_skip(ref) 

335 count += 1 

336 if ref.run == self.output_run: 

337 skeleton.set_output_in_the_way(ref) 

338 except MissingDatasetTypeError: 

339 pass 

340 self.log.verbose( 

341 "Found %d output dataset(s) of type %r in %s.", 

342 count, 

343 dataset_type_node.name, 

344 self.skip_existing_in, 

345 ) 

346 if self.output_run_exists and not self.skip_existing_starts_with_output_run: 

347 # ...or if they're in the way and would need to be 

348 # clobbered (and we haven't already found them in the 

349 # previous block). 

350 count = 0 

351 try: 

352 for ref in butler_query.datasets(dataset_type_node.name, [self.output_run]): 

353 skeleton.set_output_in_the_way(ref) 

354 count += 1 

355 except MissingDatasetTypeError: 

356 pass 

357 self.log.verbose( 

358 "Found %d output dataset(s) of type %r in %s.", 

359 count, 

360 dataset_type_node.name, 

361 self.output_run, 

362 ) 

363 # Iterate over tasks with these dimensions to perform follow-up 

364 # queries for prerequisite inputs, which may have dimensions 

365 # that were not in ``tree.all_dimensions`` and/or require 

366 # temporal joins to calibration validity ranges. 

367 for task_node in branch.tasks.values(): 

368 task_prerequisite_info = self.prerequisite_info[task_node.label] 

369 for connection_name, finder in list(task_prerequisite_info.finders.items()): 

370 if finder.lookup_function is not None: 

371 self.log.verbose( 

372 "Deferring prerequisite input %r of task %r to per-quantum processing " 

373 "(lookup function provided).", 

374 finder.dataset_type_node.name, 

375 task_node.label, 

376 ) 

377 continue 

378 # We also fall back to the base class if there is a 

379 # nontrivial spatial or temporal join in the lookup. 

380 if finder.dataset_skypix or finder.dataset_other_spatial: 

381 if task_prerequisite_info.bounds.spatial_connections: 

382 self.log.verbose( 

383 "Deferring prerequisite input %r of task %r to per-quantum processing " 

384 "(for spatial-bounds-connections handling).", 

385 finder.dataset_type_node.name, 

386 task_node.label, 

387 ) 

388 continue 

389 if not task_node.dimensions.spatial: 

390 self.log.verbose( 

391 "Deferring prerequisite input %r of task %r to per-quantum processing " 

392 "(dataset has spatial data IDs, but task does not).", 

393 finder.dataset_type_node.name, 

394 task_node.label, 

395 ) 

396 continue 

397 if finder.dataset_has_timespan: 

398 if task_prerequisite_info.bounds.spatial_connections: 

399 self.log.verbose( 

400 "Deferring prerequisite input %r of task %r to per-quantum processing " 

401 "(for temporal-bounds-connections handling).", 

402 finder.dataset_type_node.name, 

403 task_node.label, 

404 ) 

405 continue 

406 if not task_node.dimensions.temporal: 

407 self.log.verbose( 

408 "Deferring prerequisite input %r of task %r to per-quantum processing " 

409 "(dataset has temporal data IDs, but task does not).", 

410 finder.dataset_type_node.name, 

411 task_node.label, 

412 ) 

413 continue 

414 # We have a simple case where we can do a single query 

415 # that joins the query we already have for the task 

416 # data IDs to the datasets we're looking for. 

417 count = 0 

418 try: 

419 query_results = list( 

420 butler_query.join_dataset_search( 

421 finder.dataset_type_node.dataset_type, self.input_collections 

422 ) 

423 .general( 

424 dimensions | finder.dataset_type_node.dataset_type.dimensions, 

425 dataset_fields={finder.dataset_type_node.name: ...}, 

426 find_first=True, 

427 ) 

428 .iter_tuples(finder.dataset_type_node.dataset_type) 

429 ) 

430 except MissingDatasetTypeError: 

431 query_results = [] 

432 for data_id, refs, _ in query_results: 

433 ref = refs[0] 

434 dataset_key = skeleton.add_prerequisite_node(ref) 

435 quantum_key = QuantumKey( 

436 task_node.label, data_id.subset(dimensions).required_values 

437 ) 

438 skeleton.add_input_edge(quantum_key, dataset_key) 

439 count += 1 

440 # Remove this finder from the mapping so the base class 

441 # knows it doesn't have to look for these 

442 # prerequisites. 

443 del task_prerequisite_info.finders[connection_name] 

444 self.log.verbose( 

445 "Added %d prerequisite input edge(s) from dataset type %r to task %r.", 

446 count, 

447 finder.dataset_type_node.name, 

448 task_node.label, 

449 ) 

450 # Delete data ID sets we don't need anymore to save memory. 

451 del branch.data_ids 

452 

453 @timeMethod 

454 def _fetch_most_dimension_records(self, tree: _DimensionGroupTree) -> list[DimensionRecordSet]: 

455 """Query for dimension records for all non-prerequisite data IDs (and 

456 possibly some prerequisite data IDs). 

457 

458 Parameters 

459 ---------- 

460 tree : `_DimensionGroupTree` 

461 Tree with dimension group branches that holds subgraph-specific 

462 state for this builder. 

463 

464 Returns 

465 ------- 

466 dimension_records : `list` [ `lsst.daf.butler.DimensionRecordSet` ] 

467 List of sets of dimension records. 

468 

469 Notes 

470 ----- 

471 Because the initial common data ID query is used to generate all 

472 quantum and regular input/output dataset data IDs, column subsets of it 

473 can also be used to fetch dimension records for those data IDs. 

474 """ 

475 self.log.verbose("Performing follow-up queries for dimension records.") 

476 result: list[DimensionRecordSet] = [] 

477 for branch in tree.branches_by_dimensions.values(): 

478 if not branch.dimension_records: 

479 continue 

480 if not branch.data_ids: 

481 continue 

482 with self.butler.query() as butler_query: 

483 butler_query = butler_query.join_data_coordinates(branch.data_ids) 

484 for record_set in branch.dimension_records: 

485 record_set.update(butler_query.dimension_records(record_set.element.name)) 

486 result.append(record_set) 

487 return result 

488 

489 

490@dataclasses.dataclass(eq=False, repr=False, slots=True) 

491class _DimensionGroupTwig: 

492 """A small side-branch of the tree of dimensions groups that tracks the 

493 tasks and dataset types with a particular set of dimensions that appear in 

494 the edges populated by its parent branch. 

495 

496 See `_DimensionGroupTree` for more details. 

497 """ 

498 

499 parent_edge_tasks: set[str] = dataclasses.field(default_factory=set) 

500 """Task labels for tasks whose quanta have the dimensions of this twig and 

501 are endpoints of edges that have the combined dimensions of this twig's 

502 parent branch. 

503 """ 

504 

505 parent_edge_dataset_types: set[str] = dataclasses.field(default_factory=set) 

506 """Dataset type names for datasets whose quanta have the dimensions of this 

507 twig and are endpoints of edges that have the combined dimensions of this 

508 twig's parent branch. 

509 """ 

510 

511 

512@dataclasses.dataclass(eq=False, repr=False, slots=True) 

513class _DimensionGroupBranch: 

514 """A node in the tree of dimension groups that are used to recursively 

515 process query data IDs into a quantum graph. 

516 """ 

517 

518 tasks: dict[str, TaskNode] = dataclasses.field(default_factory=dict) 

519 """The task nodes whose quanta have these dimensions, keyed by task label. 

520 """ 

521 

522 dataset_types: dict[str, DatasetTypeNode] = dataclasses.field(default_factory=dict) 

523 """The dataset type nodes whose datasets have these dimensions, keyed by 

524 dataset type name. 

525 """ 

526 

527 dimension_records: list[DimensionRecordSet] = dataclasses.field(default_factory=list) 

528 """Sets of dimension records looked up with these dimensions.""" 

529 

530 data_ids: set[DataCoordinate] = dataclasses.field(default_factory=set) 

531 """All data IDs with these dimensions seen in the QuantumGraph.""" 

532 

533 input_edges: list[tuple[str, str]] = dataclasses.field(default_factory=list) 

534 """Dataset type -> task edges that are populated by this set of dimensions. 

535 

536 These are cases where `dimensions` is the union of the task and dataset 

537 type dimensions. 

538 """ 

539 

540 output_edges: list[tuple[str, str]] = dataclasses.field(default_factory=list) 

541 """Task -> dataset type edges that are populated by this set of dimensions. 

542 

543 These are cases where `dimensions` is the union of the task and dataset 

544 type dimensions. 

545 """ 

546 

547 branches: dict[DimensionGroup, _DimensionGroupBranch] = dataclasses.field(default_factory=dict) 

548 """Child branches whose dimensions are strict subsets of this branch's 

549 dimensions, populated by projecting this branch's set of data IDs (i.e. 

550 remove a dimension, then deduplicate). 

551 """ 

552 

553 twigs: defaultdict[DimensionGroup, _DimensionGroupTwig] = dataclasses.field( 

554 default_factory=lambda: defaultdict(_DimensionGroupTwig) 

555 ) 

556 """Small branches for all of the dimensions that appear on one side of any 

557 edge in `input_edges` or `output_edges`. 

558 """ 

559 

560 def pprint( 

561 self, 

562 dimensions: DimensionGroup, 

563 indent: str = " ", 

564 suffix: str = "", 

565 printer: Callable[[str], None] = print, 

566 ) -> None: 

567 printer(f"{indent}{dimensions}{suffix}") 

568 for branch_dimensions, branch in self.branches.items(): 

569 branch.pprint(branch_dimensions, indent + " ", printer=printer) 

570 

571 def project_data_ids(self, log: LsstLogAdapter, log_indent: str = " ") -> None: 

572 """Populate the data ID sets of child branches from the data IDs in 

573 this branch, recursively. 

574 

575 Parameters 

576 ---------- 

577 log : `lsst.logging.LsstLogAdapter` 

578 Logger to use for status reporting. 

579 log_indent : `str`, optional 

580 Indentation to prefix the log message. This is used when recursing 

581 to make the branch structure clear. 

582 """ 

583 for data_id in self.data_ids: 

584 for branch_dimensions, branch in self.branches.items(): 

585 branch.data_ids.add(data_id.subset(branch_dimensions)) 

586 for branch_dimensions, branch in self.branches.items(): 

587 log.verbose("%sProjecting query data ID(s) to %s.", log_indent, branch_dimensions) 

588 branch.project_data_ids(log, log_indent + " ") 

589 

590 def update_skeleton_nodes(self, skeleton: QuantumGraphSkeleton) -> None: 

591 """Process the data ID sets of this branch and its children recursively 

592 to add nodes and edges to the under-construction quantum graph. 

593 

594 Parameters 

595 ---------- 

596 skeleton : `QuantumGraphSkeleton` 

597 Under-construction quantum graph to modify in place. 

598 """ 

599 for data_id in self.data_ids: 

600 for task_label in self.tasks: 

601 skeleton.add_quantum_node(task_label, data_id) 

602 for dataset_type_name in self.dataset_types: 

603 skeleton.add_dataset_node(dataset_type_name, data_id) 

604 

605 def update_skeleton_edges(self, skeleton: QuantumGraphSkeleton) -> None: 

606 """Process the data ID sets of this branch and its children recursively 

607 to add nodes and edges to the under-construction quantum graph. 

608 

609 Parameters 

610 ---------- 

611 skeleton : `QuantumGraphSkeleton` 

612 Under-construction quantum graph to modify in place. 

613 """ 

614 for data_id in self.data_ids: 

615 quantum_keys: dict[str, QuantumKey] = {} 

616 dataset_keys: dict[str, DatasetKey] = {} 

617 for twig_dimensions, twig in self.twigs.items(): 

618 twig_data_id = data_id.subset(twig_dimensions) 

619 for task_label in twig.parent_edge_tasks: 

620 quantum_keys[task_label] = QuantumKey(task_label, twig_data_id.required_values) 

621 for dataset_type_name in twig.parent_edge_dataset_types: 

622 dataset_keys[dataset_type_name] = DatasetKey( 

623 dataset_type_name, twig_data_id.required_values 

624 ) 

625 for dataset_type_name, task_label in self.input_edges: 

626 skeleton.add_input_edge(quantum_keys[task_label], dataset_keys[dataset_type_name]) 

627 for task_label, dataset_type_name in self.output_edges: 

628 skeleton.add_output_edge(quantum_keys[task_label], dataset_keys[dataset_type_name]) 

629 if not self.dataset_types and not self.tasks: 

630 # Delete data IDs we don't need anymore to save memory. 

631 del self.data_ids 

632 

633 

634@dataclasses.dataclass(eq=False, repr=False) 

635class _DimensionGroupTree: 

636 """A tree of dimension groups in which branches are subsets of their 

637 parents. 

638 

639 This class holds all of the per-subgraph state for this QG builder 

640 subclass. 

641 

642 Notes 

643 ----- 

644 The full set of dimensions referenced by any task or dataset type (except 

645 prerequisite inputs) forms the conceptual "trunk" of this tree. Each 

646 branch has a subset of the dimensions of its parent branch, and each set 

647 of dimensions appears exactly once in a tree (so there is some flexibility 

648 in where certain dimension subsets may appear; right now this is resolved 

649 somewhat arbitrarily). 

650 We do not add branches for every possible dimension subset; a branch is 

651 created for a `~lsst.daf.butler.DimensionGroup` if: 

652 

653 - if there is a task whose quanta have those dimensions; 

654 - if there is a non-prerequisite dataset type with those dimensions; 

655 - if there is an edge for which the union of the task and dataset type 

656 dimensions are those dimensions; 

657 - if there is a dimension element in any task or non-prerequisite dataset 

658 type dimensions whose `~lsst.daf.butler.DimensionElement.minimal_group` 

659 is those dimensions (allowing us to look up dimension records). 

660 

661 In addition, for any dimension group that has unqueryable dimensions (e.g. 

662 non-common skypix dimensions, like healpix), we create a branch for the 

663 subset of the group with only queryable dimensions. 

664 

665 We process the initial data query by recursing through this tree structure 

666 to populate a data ID set for each branch 

667 (`_DimensionGroupBranch.project_data_ids`), and then process those sets. 

668 This can be far faster than the non-recursive processing the QG builder 

669 used to use because the set of data IDs is smaller (sometimes dramatically 

670 smaller) as we move to smaller sets of dimensions. 

671 

672 In addition to their child branches, a branch that is used to define graph 

673 edges also has "twigs", which are a flatter set of dimension subsets for 

674 each of the tasks and dataset types that appear in that branch's edges. 

675 The same twig dimensions can appear in multiple branches, and twig 

676 dimensions can be the same as their parent branch's (but not a superset). 

677 """ 

678 

679 subgraph: PipelineGraph 

680 """Graph of this subset of the pipeline.""" 

681 

682 all_dimensions: DimensionGroup = dataclasses.field(init=False) 

683 """The union of all dimensions that appear in any task or 

684 (non-prerequisite) dataset type in this subgraph. 

685 """ 

686 

687 queryable_dimensions: DimensionGroup = dataclasses.field(init=False) 

688 """All dimensions except those that cannot be queried for directly via the 

689 butler (e.g. skypix systems other than the common one). 

690 """ 

691 

692 branches_by_dimensions: dict[DimensionGroup, _DimensionGroupBranch] = dataclasses.field(init=False) 

693 """The tasks and dataset types of this subset of the pipeline, grouped 

694 by their dimensions. 

695 """ 

696 

697 dataset_constraint: set[str] = dataclasses.field(default_factory=set) 

698 """The names of dataset types used as query constraints.""" 

699 

700 queryable_branches: dict[DimensionGroup, _DimensionGroupBranch] = dataclasses.field(default_factory=dict) 

701 """The top-level branches in the tree of dimension groups populated by the 

702 butler query. 

703 

704 Data IDs in these branches are populated from the top down, with each 

705 branch a projection ("remove dimension, then deduplicate") of its parent, 

706 starting with the query result rows. 

707 """ 

708 

709 generators: list[DataIdGenerator] = dataclasses.field(default_factory=list) 

710 """Branches for dimensions groups that are populated by algorithmically 

711 generating data IDs from those in one or more other branches. 

712 

713 These are typically variants on the theme of adding a skypix dimension to 

714 another set of dimensions by identifying the sky pixels that overlap the 

715 region of the original dimensions. 

716 """ 

717 

718 def __post_init__(self) -> None: 

719 universe = self.subgraph.universe 

720 assert universe is not None, "Pipeline graph is resolved." 

721 self.branches_by_dimensions = { 

722 dimensions: _DimensionGroupBranch(tasks, dataset_types) 

723 for dimensions, (tasks, dataset_types) in self.subgraph.group_by_dimensions().items() 

724 } 

725 self.all_dimensions = DimensionGroup.union(*self.branches_by_dimensions.keys(), universe=universe) 

726 

727 def build( 

728 self, 

729 requested: DatasetQueryConstraintVariant, 

730 data_id_tables: Iterable[astropy.table.Table], 

731 *, 

732 log: LsstLogAdapter, 

733 ) -> None: 

734 """Organize the branches into a tree. 

735 

736 Parameters 

737 ---------- 

738 requested : `DatasetQueryConstraintVariant` 

739 Query constraint specified by the user. 

740 data_id_tables : `~collections.abc.Iterable` [ `astropy.table.Table` ] 

741 Data ID tables being joined into the query. 

742 log : `lsst.log.LsstLogAdapter` 

743 Logger that supports ``verbose`` output. 

744 """ 

745 universe = self.all_dimensions.universe 

746 self._make_dimension_record_branches() 

747 self._make_edge_branches() 

748 self._set_dataset_constraint(requested, log) 

749 # Work out which dimensions we can potentially query the database for. 

750 # We start out by dropping all skypix dimensions other than the common 

751 # one, and then we add them back in if a constraint dataset type or 

752 # data ID table provides them. 

753 unqueryable_skypix = universe.conform(self.all_dimensions.skypix - {universe.commonSkyPix.name}) 

754 self.queryable_dimensions = self.all_dimensions.difference(unqueryable_skypix) 

755 for dataset_type_name in sorted(self.dataset_constraint): 

756 dataset_type_dimensions = self.subgraph.dataset_types[dataset_type_name].dimensions 

757 dataset_type_skypix = dataset_type_dimensions.intersection(unqueryable_skypix) 

758 if dataset_type_skypix: 

759 log.info( 

760 f"Including {dataset_type_skypix} in the set of dimensions to query via " 

761 f"{dataset_type_name}. If this query fails, exclude those dataset type " 

762 "from the constraint or provide a data ID table for missing spatial joins." 

763 ) 

764 self.queryable_dimensions = self.queryable_dimensions.union(dataset_type_dimensions) 

765 for data_id_table in data_id_tables: 

766 table_dimensions = universe.conform(data_id_table.colnames) 

767 if table_dimensions.skypix: 

768 self.queryable_dimensions = self.queryable_dimensions.union(table_dimensions) 

769 # Set up the tree to generate most data IDs by querying for them from 

770 # the database and then projecting to subset dimensions. 

771 branches_not_in_tree = set(self.branches_by_dimensions.keys()) 

772 self._make_queryable_branch_tree(branches_not_in_tree) 

773 # Try to find ways to generate other data IDs directly from the 

774 # queryable branches. 

775 self._make_queryable_overlap_branch_generators(branches_not_in_tree) 

776 # As long as there are still branches that haven't been inserted into 

777 # the tree, try to add them as projections of generated branches or 

778 # generators on generated branches. 

779 while branches_not_in_tree: 

780 # Look for projections first, since those are more efficient, and 

781 # some may be available after we've added some generators. 

782 # We intentionally add the same branch as a projection of multiple 

783 # parents since (unlike queryable dimensions) there's no guarantee 

784 # that each parent branch's data IDs would project to the same set 

785 # (e.g. a visit-healpix overlap may yield different healpixels than 

786 # a patch-healpix overlap, even if the visits and patches overlap). 

787 for target_dimensions in sorted(branches_not_in_tree): 

788 for generator in self.generators: 

789 if self._maybe_insert_projection_branch( 

790 target_dimensions, generator.dimensions, generator.branch.branches 

791 ): 

792 branches_not_in_tree.discard(target_dimensions) 

793 if not self._make_general_overlap_branch_generator(branches_not_in_tree): 

794 break 

795 # After we've exhausted overlap generation, try generation via joins 

796 # of dimensions we can already query for or generate. 

797 while branches_not_in_tree: 

798 if not self._make_join_branch_generator(branches_not_in_tree): 

799 raise QuantumGraphBuilderError(f"Could not generate data IDs for {branches_not_in_tree}.") 

800 

801 def _set_dataset_constraint(self, requested: DatasetQueryConstraintVariant, log: LsstLogAdapter) -> None: 

802 """Set the dataset query constraint. 

803 

804 Parameters 

805 ---------- 

806 requested : `DatasetQueryConstraintVariant` 

807 Query constraint specified by the user. 

808 log : `lsst.log.LsstLogAdapter` 

809 Logger that supports ``verbose`` output. 

810 """ 

811 overall_inputs: dict[str, DatasetTypeNode] = { 

812 name: node # type: ignore 

813 for name, node in self.subgraph.iter_overall_inputs() 

814 if not node.is_prerequisite # type: ignore 

815 } 

816 match requested: 

817 case DatasetQueryConstraintVariant.ALL: 

818 self.dataset_constraint = { 

819 name 

820 for name, dataset_type_node in overall_inputs.items() 

821 if (dataset_type_node.is_initial_query_constraint and dataset_type_node.dimensions) 

822 } 

823 case DatasetQueryConstraintVariant.OFF: 

824 pass 

825 case DatasetQueryConstraintVariant.LIST: 

826 self.dataset_constraint = set(requested) 

827 inputs = { 

828 name for name, dataset_type_node in overall_inputs.items() if dataset_type_node.dimensions 

829 } 

830 if remainder := self.dataset_constraint.difference(inputs): 

831 log.verbose( 

832 "Ignoring dataset types %s in dataset query constraint that are not inputs to this " 

833 "subgraph, on the assumption that they are relevant for a different subgraph.", 

834 remainder, 

835 ) 

836 self.dataset_constraint.intersection_update(inputs) 

837 case _: 

838 raise QuantumGraphBuilderError( 

839 f"Unable to handle type {requested} given as dataset query constraint." 

840 ) 

841 

842 def _make_dimension_record_branches(self) -> None: 

843 """Ensure we have branches for all dimension elements we'll need to 

844 fetch dimension records for. 

845 """ 

846 for element_name in self.all_dimensions.elements: 

847 element = self.all_dimensions.universe[element_name] 

848 record_set = DimensionRecordSet(element_name, universe=self.all_dimensions.universe) 

849 if element.minimal_group in self.branches_by_dimensions: 

850 self.branches_by_dimensions[element.minimal_group].dimension_records.append(record_set) 

851 else: 

852 self.branches_by_dimensions[element.minimal_group] = _DimensionGroupBranch( 

853 dimension_records=[record_set] 

854 ) 

855 

856 def _make_edge_branches(self) -> None: 

857 """Ensure we have branches for all edges in the graph.""" 

858 

859 def update_edge_branch( 

860 task_node: TaskNode, dataset_type_node: DatasetTypeNode 

861 ) -> _DimensionGroupBranch: 

862 union_dimensions = task_node.dimensions.union(dataset_type_node.dimensions) 

863 if (branch := self.branches_by_dimensions.get(union_dimensions)) is None: 

864 branch = _DimensionGroupBranch() 

865 self.branches_by_dimensions[union_dimensions] = branch 

866 branch.twigs[dataset_type_node.dimensions].parent_edge_dataset_types.add(dataset_type_node.name) 

867 branch.twigs[task_node.dimensions].parent_edge_tasks.add(task_node.label) 

868 return branch 

869 

870 for task_node in self.subgraph.tasks.values(): 

871 for dataset_type_node in self.subgraph.inputs_of(task_node.label).values(): 

872 assert dataset_type_node is not None, "Pipeline graph is resolved." 

873 if dataset_type_node.is_prerequisite: 

874 continue 

875 branch = update_edge_branch(task_node, dataset_type_node) 

876 branch.input_edges.append((dataset_type_node.name, task_node.label)) 

877 for dataset_type_node in self.subgraph.outputs_of(task_node.label).values(): 

878 assert dataset_type_node is not None, "Pipeline graph is resolved." 

879 branch = update_edge_branch(task_node, dataset_type_node) 

880 branch.output_edges.append((task_node.label, dataset_type_node.name)) 

881 

882 def _make_queryable_branch_tree(self, branches_not_in_tree: set[DimensionGroup]) -> None: 

883 """Assemble the branches with queryable dimensions into a tree, in 

884 which each branch has a subset of the dimensions of its parent. 

885 

886 Parameters 

887 ---------- 

888 branches_not_in_tree : `set` [ `lsst.daf.butler.DimensionGroup` ] 

889 Dimensions that have not yet been inserted into the tree. Updated 

890 in place. 

891 """ 

892 for target_dimensions in sorted(branches_not_in_tree): 

893 if target_dimensions.issubset(self.queryable_dimensions): 

894 if self._maybe_insert_projection_branch( 

895 target_dimensions, self.queryable_dimensions, self.queryable_branches 

896 ): 

897 branches_not_in_tree.remove(target_dimensions) 

898 else: 

899 raise AssertionError( 

900 "Projection-branch insertion should not fail for queryable dimensions." 

901 ) 

902 

903 def _maybe_insert_projection_branch( 

904 self, 

905 target_dimensions: DimensionGroup, 

906 candidate_dimensions: DimensionGroup, 

907 candidate_projection_branches: dict[DimensionGroup, _DimensionGroupBranch], 

908 ) -> bool: 

909 """Insert a branch at the appropriate location in a [sub]tree. 

910 

911 Branches are inserted below the first parent branch whose dimensions 

912 are a superset of their own. 

913 

914 Parameters 

915 ---------- 

916 target_dimensions : `lsst.daf.butler.DimensionGroup` 

917 Dimensions of the branch to be inserted. 

918 candidate_dimensions : `lsst.daf.butler.DimensionGroup` 

919 Dimensions of the subtree the branch might be inserted under. If 

920 this is not a superset of ``target_dimensions``, this method 

921 returns `False` and nothing is done. 

922 candidate_projection_branches : `dict` [ \ 

923 `lsst.daf.butler.DimensionGroup`, `_DimensionGroupBranch` ] 

924 Subtree branches to be updated directly or indirectly (i.e. in a 

925 nested branch). 

926 

927 Returns 

928 ------- 

929 inserted : `bool` 

930 Whether the branch was actually inserted. 

931 """ 

932 if candidate_dimensions >= target_dimensions: 

933 target_branch = self.branches_by_dimensions[target_dimensions] 

934 for child_dimensions in list(candidate_projection_branches.keys()): 

935 if self._maybe_insert_projection_branch( 

936 child_dimensions, target_dimensions, target_branch.branches 

937 ): 

938 del candidate_projection_branches[child_dimensions] 

939 for child_dimensions, child_branch in candidate_projection_branches.items(): 

940 if self._maybe_insert_projection_branch( 

941 target_dimensions, child_dimensions, child_branch.branches 

942 ): 

943 return True 

944 candidate_projection_branches[target_dimensions] = target_branch 

945 return True 

946 return False 

947 

948 def _make_queryable_overlap_branch_generators(self, branches_not_in_tree: set[DimensionGroup]) -> None: 

949 """Add data ID generators for sets of dimensions that can only 

950 partially queried for, with the rest needing to be generated by 

951 manipulating the data IDs of the queryable subset. 

952 

953 Parameters 

954 ---------- 

955 branches_not_in_tree : `set` [ `lsst.daf.butler.DimensionGroup` ] 

956 Dimensions that have not yet been inserted into the tree. Updated 

957 in place. 

958 """ 

959 for target_dimensions in sorted(branches_not_in_tree): 

960 queryable_subset_dimensions = target_dimensions.intersection(self.queryable_dimensions) 

961 # Make sure we actually have a branch to capture the queryable 

962 # subset data IDs (i.e. in case we didn't already have one for some 

963 # dataset type or task, etc). 

964 if queryable_subset_dimensions not in self.branches_by_dimensions: 

965 # If we have to make a new queryable branch, we also have to 

966 # insert it into the tree so its data IDs get populated. 

967 self.branches_by_dimensions[queryable_subset_dimensions] = _DimensionGroupBranch() 

968 if not self._maybe_insert_projection_branch( 

969 queryable_subset_dimensions, 

970 self.queryable_dimensions, 

971 self.queryable_branches, 

972 ): 

973 raise AssertionError( 

974 "Projection-branch insertion should not fail for queryable dimensions." 

975 ) 

976 if queryable_region_name := queryable_subset_dimensions.region_dimension: 

977 # If there is a single well-defined region for the queryable 

978 # subset, we can potentially generate skypix IDs from it. 

979 # Do the target dimensions just add a single skypix dimension 

980 # to the queryable subset? 

981 remainder_dimensions = target_dimensions - queryable_subset_dimensions 

982 if (remainder_skypix := get_single_skypix(remainder_dimensions)) is not None: 

983 queryable_region_element = target_dimensions.universe[queryable_region_name] 

984 self._append_data_id_generator( 

985 queryable_subset_dimensions, 

986 queryable_region_element, 

987 target_dimensions, 

988 remainder_skypix, 

989 branches_not_in_tree, 

990 ) 

991 

992 def _append_data_id_generator( 

993 self, 

994 source_dimensions: DimensionGroup, 

995 source_region_element: DimensionElement, 

996 target_dimensions: DimensionGroup, 

997 remainder_skypix: SkyPixDimension, 

998 branches_not_in_tree: set[DimensionGroup], 

999 ) -> None: 

1000 """Append an appropriate `DataIdGenerator` instance for generating 

1001 data IDs with the given characteristics. 

1002 

1003 Parameters 

1004 ---------- 

1005 source_dimensions : `lsst.daf.butler.DimensionGroup` 

1006 Dimensions whose data IDs can already populated, to use as a 

1007 starting point. 

1008 source_region_element : `lsst.daf.butler.DimensionElement` 

1009 Dimension element associated with the region for the source 

1010 dimensions. It is guaranteed that there is exactly one such 

1011 region. 

1012 target_dimensions : `lsst.daf.butler.DimensionGroup` 

1013 Dimensions of the data IDs to be generated. 

1014 remainder_skypix : `lsst.daf.butler.SkyPixDimension` 

1015 The single skypix dimension that is being added to 

1016 ``source_dimensions`` to yield ``target_dimensions``. 

1017 branches_not_in_tree : `set` [ `lsst.daf.butler.DimensionGroup` ] 

1018 Dimensions that have not yet been inserted into the tree. Updated 

1019 in place. 

1020 """ 

1021 target_branch = self.branches_by_dimensions[target_dimensions] 

1022 # We want to do the overlap calculation without any extra dimensions 

1023 # beyond the two spatial dimensions, which may or may not be what we 

1024 # already have. 

1025 overlap_dimensions = source_region_element.minimal_group | remainder_skypix.minimal_group 

1026 generator: DataIdGenerator 

1027 if overlap_dimensions == target_dimensions: 

1028 if isinstance(source_region_element, SkyPixDimension): 

1029 if source_region_element.system == remainder_skypix.system: 

1030 if source_region_element.level > remainder_skypix.level: 

1031 generator = SkyPixGatherDataIdGenerator( 

1032 target_branch, 

1033 target_dimensions, 

1034 source_dimensions, 

1035 remainder_skypix, 

1036 source_region_element, 

1037 ) 

1038 else: 

1039 generator = SkyPixScatterDataIdGenerator( 

1040 target_branch, 

1041 target_dimensions, 

1042 source_dimensions, 

1043 remainder_skypix, 

1044 source_region_element, 

1045 ) 

1046 else: 

1047 generator = CrossSystemDataIdGenerator( 

1048 target_branch, 

1049 target_dimensions, 

1050 source_dimensions, 

1051 remainder_skypix, 

1052 source_region_element, 

1053 ) 

1054 else: 

1055 generator = DatabaseSourceDataIdGenerator( 

1056 target_branch, 

1057 target_dimensions, 

1058 source_dimensions, 

1059 remainder_skypix, 

1060 source_region_element, 

1061 ) 

1062 # We know we can populate the data IDs in remainder_skypix_branch 

1063 # from the target branch by projection. Even if it's already 

1064 # populated by some other generated branch, we want to populate it 

1065 # again in case that picks up additional sky pixels. 

1066 target_branch.branches[remainder_skypix.minimal_group] = self.branches_by_dimensions[ 

1067 remainder_skypix.minimal_group 

1068 ] 

1069 branches_not_in_tree.discard(remainder_skypix.minimal_group) 

1070 else: 

1071 if overlap_dimensions not in self.branches_by_dimensions: 

1072 self.branches_by_dimensions[overlap_dimensions] = _DimensionGroupBranch() 

1073 branches_not_in_tree.add(overlap_dimensions) 

1074 self._append_data_id_generator( 

1075 source_region_element.minimal_group, 

1076 source_region_element, 

1077 overlap_dimensions, 

1078 remainder_skypix, 

1079 branches_not_in_tree, 

1080 ) 

1081 generator = JoinDataIdGenerator( 

1082 target_branch, 

1083 target_dimensions, 

1084 source_dimensions, 

1085 overlap_dimensions, 

1086 ) 

1087 self.generators.append(generator) 

1088 branches_not_in_tree.remove(target_dimensions) 

1089 

1090 def _make_general_overlap_branch_generator(self, branches_not_in_tree: set[DimensionGroup]) -> bool: 

1091 """Add data ID generators for sets of dimensions that can be generated 

1092 via skypix envelopes of other generated data IDs. 

1093 

1094 This method should be called in a loop until it returns `False` 

1095 (indicating no progress was made) or ``branches_not_in_tree`` is empty 

1096 (indicating no more work to be done). 

1097 

1098 Parameters 

1099 ---------- 

1100 branches_not_in_tree : `set` [ `lsst.daf.butler.DimensionGroup` ] 

1101 Dimensions that have not yet been inserted into the tree. Updated 

1102 in place. 

1103 

1104 Returns 

1105 ------- 

1106 appended : `bool` 

1107 Whether a new data ID generator was successfully appended. 

1108 """ 

1109 dimensions_done = sorted(self.branches_by_dimensions.keys() - branches_not_in_tree) 

1110 for source_dimensions in dimensions_done: 

1111 for target_dimensions in sorted(branches_not_in_tree): 

1112 if not source_dimensions <= target_dimensions: 

1113 continue 

1114 remainder_dimensions = target_dimensions - source_dimensions 

1115 if (remainder_skypix := get_single_skypix(remainder_dimensions)) is not None: 

1116 if source_region_name := source_dimensions.region_dimension: 

1117 # If the target dimensions are just adding a single 

1118 # skypix to the source dimensions and the source 

1119 # dimensions have a single region column, we can 

1120 # generate the skypix indices from the envelopes of 

1121 # those regions. 

1122 source_region_element = source_dimensions.universe[source_region_name] 

1123 self._append_data_id_generator( 

1124 source_dimensions, 

1125 source_region_element, 

1126 target_dimensions, 

1127 remainder_skypix, 

1128 branches_not_in_tree, 

1129 ) 

1130 return True 

1131 return not branches_not_in_tree 

1132 

1133 def _make_join_branch_generator(self, branches_not_in_tree: set[DimensionGroup]) -> bool: 

1134 """Add data ID generators for sets of dimensions that can be generated 

1135 via inner joints of other generated data IDs. 

1136 

1137 This method should be called in a loop until it returns `False` 

1138 (indicating no progress was made) or ``branches_not_in_tree`` is empty 

1139 (indicating no more work to be done). 

1140 

1141 Parameters 

1142 ---------- 

1143 branches_not_in_tree : `set` [ `lsst.daf.butler.DimensionGroup` ] 

1144 Dimensions that have not yet been inserted into the tree. Updated 

1145 in place. 

1146 

1147 Returns 

1148 ------- 

1149 appended : `bool` 

1150 Whether a new data ID generator was successfully appended. 

1151 """ 

1152 for target_dimensions in sorted(branches_not_in_tree): 

1153 dimensions_done = sorted(self.branches_by_dimensions.keys() - branches_not_in_tree) 

1154 candidates_by_common: dict[DimensionGroup, tuple[DimensionGroup, DimensionGroup]] = {} 

1155 for operand1, operand2 in itertools.combinations(dimensions_done, 2): 

1156 if operand1.union(operand2) == target_dimensions: 

1157 candidates_by_common[operand1.intersection(operand2)] = (operand1, operand2) 

1158 if candidates_by_common: 

1159 # Because DimensionGroup defines a set-like inequality 

1160 # operator, 'max' returns the set of dimensions that contains 

1161 # as many of the other sets of dimensions as possible, which is 

1162 # a reasonable guess at the most-constrained join. 

1163 operand1, operand2 = candidates_by_common[max(candidates_by_common)] 

1164 generator = JoinDataIdGenerator( 

1165 self.branches_by_dimensions[target_dimensions], 

1166 target_dimensions, 

1167 operand1, 

1168 operand2, 

1169 ) 

1170 self.generators.append(generator) 

1171 branches_not_in_tree.remove(target_dimensions) 

1172 return True 

1173 return not branches_not_in_tree 

1174 

1175 def project_data_ids(self, log: LsstLogAdapter) -> None: 

1176 """Recursively populate the data ID sets of the dimension group tree 

1177 from the data ID sets of the queryable branches. 

1178 

1179 Parameters 

1180 ---------- 

1181 log : `lsst.logging.LsstLogAdapter` 

1182 Logger to use for status reporting. 

1183 """ 

1184 for branch_dimensions, branch in self.queryable_branches.items(): 

1185 log.verbose("Projecting query data ID(s) to %s.", branch_dimensions) 

1186 branch.project_data_ids(log) 

1187 

1188 def generate_data_ids(self, log: LsstLogAdapter) -> None: 

1189 """Run all data ID generators. 

1190 

1191 This runs data ID generators and projects data IDs to their subset 

1192 dimensions. It can only be called after queryable data IDs have been 

1193 populated and dimension records fetched. 

1194 

1195 Parameters 

1196 ---------- 

1197 log : `lsst.logging.LsstLogAdapter` 

1198 Logger to use for status reporting. 

1199 """ 

1200 for generator in self.generators: 

1201 generator.run(log, self.branches_by_dimensions) 

1202 generator.branch.project_data_ids(log, log_indent=" ") 

1203 

1204 def pprint(self, printer: Callable[[str], None] = print) -> None: 

1205 """Print a human-readable representation of the dimensions tree. 

1206 

1207 Parameters 

1208 ---------- 

1209 printer : `~collections.abc.Callable`, optional 

1210 A function that takes a single string argument and prints a single 

1211 line (including a newline). Default is the built-in `print` 

1212 function. 

1213 """ 

1214 printer("Queryable:") 

1215 for branch_dimensions, branch in self.queryable_branches.items(): 

1216 branch.pprint(branch_dimensions, " ", printer=printer) 

1217 printer("Generator:") 

1218 for generator in self.generators: 

1219 generator.pprint(" ", printer=printer) 

1220 

1221 

1222def get_single_skypix(dimensions: DimensionGroup) -> SkyPixDimension | None: 

1223 """Try to coerce a dimension group a single skypix dimenison. 

1224 

1225 Parameters 

1226 ---------- 

1227 dimensions : `lsst.daf.butler.DimensionGroup` 

1228 Input dimensions. 

1229 

1230 Returns 

1231 ------- 

1232 skypix : `lsst.daf.butler.SkyPixDimension` or `None` 

1233 A skypix dimension that is the only dimension in the given group, or 

1234 `None` in all other cases. 

1235 """ 

1236 if len(dimensions) == 1: 

1237 (name,) = dimensions.names 

1238 return dimensions.universe.skypix_dimensions.get(name) 

1239 return None 

1240 

1241 

1242@dataclasses.dataclass 

1243class DataIdGenerator: 

1244 """A base class for generators for quantum and dataset data IDs that cannot 

1245 be directly queried for. 

1246 """ 

1247 

1248 branch: _DimensionGroupBranch 

1249 """Branch of the dimensions tree that this generator populates.""" 

1250 

1251 dimensions: DimensionGroup 

1252 """Dimensions of the data IDs generated.""" 

1253 

1254 source: DimensionGroup 

1255 """Dimensions of another set of data IDs that this generator uses as a 

1256 starting point. 

1257 """ 

1258 

1259 def pprint(self, indent: str = " ", printer: Callable[[str], None] = print) -> None: 

1260 """Print a human-readable representation of this generator. 

1261 

1262 Parameters 

1263 ---------- 

1264 indent : `str` 

1265 Blank spaces to prefix the output with (useful when this is nested 

1266 in hierarchical object being printed). 

1267 printer : `~collections.abc.Callable`, optional 

1268 A function that takes a single string argument and prints a single 

1269 line (including a newline). Default is the built-in `print` 

1270 function. 

1271 """ 

1272 self.branch.pprint( 

1273 self.dimensions, 

1274 indent, 

1275 f" <- {self.source} ({self.__class__.__name__})", 

1276 printer=printer, 

1277 ) 

1278 

1279 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1280 """Run the generator, populating its branch's data IDs. 

1281 

1282 Parameters 

1283 ---------- 

1284 log : `lsst.log.LsstLogAdapter` 

1285 Logger with a ``verbose`` method as well as the built-in ones. 

1286 branches : `~collections.abc.Mapping` 

1287 Mapping of other dimension branches, keyed by their dimensions. 

1288 """ 

1289 raise NotImplementedError() 

1290 

1291 

1292@dataclasses.dataclass 

1293class DatabaseSourceDataIdGenerator(DataIdGenerator): 

1294 """A data ID generator that generates skypix indices from the envelope of 

1295 regions stored in the database. 

1296 """ 

1297 

1298 remainder_skypix: SkyPixDimension 

1299 """A single additional skypix dimension to be added to the source 

1300 dimensions. 

1301 """ 

1302 

1303 source_element: DimensionElement 

1304 """Dimension element that the database-stored regions are associated with. 

1305 """ 

1306 

1307 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1308 # Docstring inherited. 

1309 source_branch = branches[self.source] 

1310 log.verbose( 

1311 "Generating %s data IDs via %s envelope of %s %s region(s).", 

1312 self.dimensions, 

1313 self.remainder_skypix, 

1314 len(source_branch.data_ids), 

1315 self.source_element, 

1316 ) 

1317 pixelization = self.remainder_skypix.pixelization 

1318 (source_records,) = [ 

1319 record_set 

1320 for record_set in source_branch.dimension_records 

1321 if record_set.element == self.source_element 

1322 ] 

1323 for source_data_id in source_branch.data_ids: 

1324 source_record = source_records.find(source_data_id) 

1325 for begin, end in pixelization.envelope(source_record.region): 

1326 for index in range(begin, end): 

1327 target_data_id = DataCoordinate.standardize( 

1328 source_data_id, 

1329 **{self.remainder_skypix.name: index}, # type: ignore[arg-type] 

1330 ) 

1331 self.branch.data_ids.add(target_data_id) 

1332 

1333 

1334@dataclasses.dataclass 

1335class CrossSystemDataIdGenerator(DataIdGenerator): 

1336 """A data ID generator that generates skypix indices from the envelope of 

1337 skypix regions from some other system (e.g. healpix from HTM). 

1338 """ 

1339 

1340 remainder_skypix: SkyPixDimension 

1341 """A single additional skypix dimension to be added to the source 

1342 dimensions. 

1343 """ 

1344 

1345 source_skypix: SkyPixDimension 

1346 """Dimension element for the already-known skypix indices.""" 

1347 

1348 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1349 # Docstring inherited. 

1350 source_branch = branches[self.source] 

1351 log.verbose( 

1352 "Generating %s data IDs via %s envelope of %s %s region(s).", 

1353 self.dimensions, 

1354 self.remainder_skypix, 

1355 len(source_branch.data_ids), 

1356 self.source_skypix, 

1357 ) 

1358 source_pixelization = self.source_skypix.pixelization 

1359 remainder_pixelization = self.remainder_skypix.pixelization 

1360 for source_data_id in source_branch.data_ids: 

1361 source_region = source_pixelization.pixel(source_data_id[self.source_skypix.name]) 

1362 for begin, end in remainder_pixelization.envelope(source_region): 

1363 for index in range(begin, end): 

1364 target_data_id = DataCoordinate.standardize( 

1365 source_data_id, 

1366 **{self.remainder_skypix.name: index}, # type: ignore[arg-type] 

1367 ) 

1368 self.branch.data_ids.add(target_data_id) 

1369 

1370 

1371@dataclasses.dataclass 

1372class SkyPixScatterDataIdGenerator(DataIdGenerator): 

1373 """A data ID generator that generates skypix indices at a high (fine) level 

1374 from low-level (coarse) indices in the same system. 

1375 """ 

1376 

1377 remainder_skypix: SkyPixDimension 

1378 """A single additional skypix dimension to be added to the source 

1379 dimensions. 

1380 """ 

1381 

1382 source_skypix: SkyPixDimension 

1383 """Dimension element for the already-known skypix indices.""" 

1384 

1385 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1386 # Docstring inherited. 

1387 factor = 4 ** (self.remainder_skypix.level - self.source_skypix.level) 

1388 source_branch = branches[self.source] 

1389 log.verbose( 

1390 "Generating %s data IDs by scaling %s %s IDs in %s by %s.", 

1391 self.dimensions, 

1392 len(source_branch.data_ids), 

1393 self.remainder_skypix, 

1394 self.source, 

1395 factor, 

1396 ) 

1397 for source_data_id in source_branch.data_ids: 

1398 ranges = RangeSet(source_data_id[self.source_skypix.name]) 

1399 ranges.scale(factor) 

1400 for begin, end in ranges: 

1401 for index in range(begin, end): 

1402 target_data_id = DataCoordinate.standardize( 

1403 source_data_id, 

1404 **{self.remainder_skypix.name: index}, # type: ignore[arg-type] 

1405 ) 

1406 self.branch.data_ids.add(target_data_id) 

1407 

1408 

1409@dataclasses.dataclass 

1410class SkyPixGatherDataIdGenerator(DataIdGenerator): 

1411 """A data ID generator that generates skypix indices at a low (coarse) 

1412 level from high-level (fine) indices in the same system. 

1413 """ 

1414 

1415 remainder_skypix: SkyPixDimension 

1416 """A single additional skypix dimension to be added to the source 

1417 dimensions. 

1418 """ 

1419 

1420 source_skypix: SkyPixDimension 

1421 """Dimension element for the already-known skypix indices.""" 

1422 

1423 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1424 # Docstring inherited. 

1425 factor = 4 ** (self.source_skypix.level - self.remainder_skypix.level) 

1426 source_branch = branches[self.source] 

1427 log.verbose( 

1428 "Generating %s data IDs by dividing %s %s IDs in %s by %s.", 

1429 self.dimensions, 

1430 len(source_branch.data_ids), 

1431 self.remainder_skypix, 

1432 self.source, 

1433 factor, 

1434 ) 

1435 for source_data_id in source_branch.data_ids: 

1436 index = source_data_id[self.source_skypix.name] // factor 

1437 target_data_id = DataCoordinate.standardize(source_data_id, **{self.remainder_skypix.name: index}) 

1438 self.branch.data_ids.add(target_data_id) 

1439 

1440 

1441@dataclasses.dataclass 

1442class JoinDataIdGenerator(DataIdGenerator): 

1443 """A data ID that does an inner join between two already-populated 

1444 sets of data IDs. 

1445 """ 

1446 

1447 other: DimensionGroup 

1448 """Dimensions of the other data ID branches to join to those of ``source``. 

1449 """ 

1450 

1451 def run(self, log: LsstLogAdapter, branches: Mapping[DimensionGroup, _DimensionGroupBranch]) -> None: 

1452 # Docstring inherited. 

1453 source_branch = branches[self.source] 

1454 other_branch = branches[self.other] 

1455 log.verbose( 

1456 "Generating %s data IDs by joining %s (%s) to %s (%s).", 

1457 self.dimensions, 

1458 self.source, 

1459 len(source_branch.data_ids), 

1460 self.other, 

1461 len(other_branch.data_ids), 

1462 ) 

1463 common = self.source & self.other 

1464 other_by_common: defaultdict[DataCoordinate, list[DataCoordinate]] = defaultdict(list) 

1465 for other_data_id in other_branch.data_ids: 

1466 other_by_common[other_data_id.subset(common)].append(other_data_id) 

1467 source_by_common: defaultdict[DataCoordinate, list[DataCoordinate]] = defaultdict(list) 

1468 for source_data_id in source_branch.data_ids: 

1469 source_by_common[source_data_id.subset(common)].append(source_data_id) 

1470 for common_data_id in other_by_common.keys() & source_by_common.keys(): 

1471 for other_data_id in other_by_common[common_data_id]: 

1472 for source_data_id in source_by_common[common_data_id]: 

1473 self.branch.data_ids.add(other_data_id.union(source_data_id))