Coverage for python/lsst/analysis/tools/interfaces/_task.py: 37%

273 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-11 09:02 +0000

1# This file is part of analysis_tools. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://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 <https://www.gnu.org/licenses/>. 

21from __future__ import annotations 

22 

23__all__ = ("AnalysisBaseConnections", "AnalysisBaseConfig", "AnalysisPipelineTask") 

24 

25import datetime 

26import logging 

27import pprint 

28import traceback 

29import warnings 

30import weakref 

31from collections.abc import Collection, Iterable, Mapping, MutableMapping 

32from copy import deepcopy 

33from typing import TYPE_CHECKING, Any, cast 

34 

35import matplotlib.pyplot as plt 

36 

37from lsst.verify import Measurement 

38 

39if TYPE_CHECKING: 

40 from lsst.daf.butler import DatasetRef, DeferredDatasetHandle 

41 from lsst.pipe.base import QuantumContext 

42 

43from lsst.daf.butler import DataCoordinate 

44from lsst.pex.config import Field, ListField 

45from lsst.pex.config.configurableActions import ConfigurableActionStructField 

46from lsst.pipe.base import ( 

47 AlgorithmError, 

48 AnnotatedPartialOutputsError, 

49 Instrument, 

50 PipelineTask, 

51 PipelineTaskConfig, 

52 PipelineTaskConnections, 

53 Struct, 

54) 

55from lsst.pipe.base import connectionTypes as ct 

56from lsst.pipe.base.connections import InputQuantizedConnection, OutputQuantizedConnection 

57from lsst.pipe.base.pipelineIR import ConfigIR, ParametersIR 

58 

59from ._actions import JointAction, MetricAction, NoMetric 

60from ._analysisTools import AnalysisTool 

61from ._interfaces import KeyedData, PlotTypes 

62from ._metricMeasurementBundle import MetricMeasurementBundle 

63 

64"""Base class implementation for the classes needed in creating `PipelineTasks` 

65which execute `AnalysisTools`. 

66 

67The classes defined in this module have all the required behaviors for 

68defining, introspecting, and executing `AnalysisTools` against an input dataset 

69type. 

70 

71Subclasses of these tasks should specify specific datasets to consume in their 

72connection classes and should specify a unique name 

73""" 

74 

75# TODO: This rcParams modification is a temporary solution, hiding 

76# a matplotlib warning indicating too many figures have been opened. 

77# When DM-39114 is implemented, this should be removed. 

78plt.rcParams.update({"figure.max_open_warning": 0}) 

79 

80 

81# TODO: This _plotCloser function assists in closing all open plots at the 

82# conclusion of a PipelineTask. When DM-39114 is implemented, this function and 

83# all associated usage thereof should be removed. 

84def _plotCloser(*args): 

85 """Close all the plots in the given list.""" 

86 for plot in args: 

87 plt.close(plot) 

88 

89 

90class PartialAtoolsFailureError(AlgorithmError): 

91 """Raised if an analysis tools action has partially failed. 

92 

93 Parameters 

94 ---------- 

95 caughtErrors : `dict` of (`str`, `str`) 

96 A dictionary mapping the action name to the string representation of 

97 the exception raised by that action. 

98 """ 

99 

100 def __init__(self, caughtErrors) -> None: 

101 self._caughtErrors = caughtErrors 

102 errString = "These tasks have failed for the following reasons: " 

103 for errName in caughtErrors.keys(): 

104 errString += errName + ": " 

105 errString += pprint.pformat(caughtErrors[errName]) 

106 errString += ", " 

107 errString = errString[:-2] 

108 super().__init__(errString) 

109 

110 @property 

111 def metadata(self) -> dict: 

112 return self._caughtErrors 

113 

114 

115class AnalysisBaseConnections( 

116 PipelineTaskConnections, dimensions={}, defaultTemplates={"outputName": "Placeholder"} 

117): 

118 r"""Base class for Connections used for AnalysisTools PipelineTasks. 

119 

120 This class has a pre-defined output connection for the 

121 MetricMeasurementMapping. The dataset type name for this connection is 

122 determined by the template ``outputName``. 

123 

124 Output connections for plots created by `AnalysisPlot`\ s are created 

125 dynamically when an instance of the class is created. The init method 

126 examines all the `AnalysisPlot` actions specified in the associated 

127 `AnalysisBaseConfig` subclass accumulating all the info needed to 

128 create the output connections. 

129 

130 The dimensions for all of the output connections (metric and plot) will 

131 be the same as the dimensions specified for the AnalysisBaseConnections 

132 subclass (i.e. quantum dimensions). 

133 """ 

134 

135 metrics = ct.Output( 

136 doc="Metrics calculated on input dataset type", 

137 name="{outputName}_metrics", 

138 storageClass="MetricMeasurementBundle", 

139 ) 

140 

141 def __init__(self, *, config: AnalysisBaseConfig = None): # type: ignore 

142 # Validate that the outputName template has been set in config. This 

143 # should have been checked early with the configs validate method, but 

144 # it is possible for someone to manually create everything in a script 

145 # without running validate, so also check it late here. 

146 if (outputName := config.connections.outputName) == "Placeholder": # type: ignore 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 raise RuntimeError( 

148 "Subclasses must specify an alternative value for the defaultTemplate `outputName`" 

149 ) 

150 super().__init__(config=config) 

151 

152 # All arguments must be passed by kw, but python has not method to do 

153 # that without specifying a default, so None is used. Validate that 

154 # it is not None. This is largely for typing reasons, as in the normal 

155 # course of operation code execution paths ensure this will not be None 

156 assert config is not None 

157 

158 for tool in config.atools: 

159 match tool.produce: 

160 case JointAction(): 160 ↛ 166line 160 didn't jump to line 166 because the pattern on line 160 always matched

161 if isinstance(tool.produce.metric, NoMetric): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 continue 

163 if len(tool.produce.metric.units) != 0: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 hasMetrics = True 

165 break 

166 case MetricAction(): 

167 hasMetrics = True 

168 break 

169 else: 

170 hasMetrics = False 

171 

172 # Set the dimensions for the metric 

173 if hasMetrics: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 self.metrics = ct.Output( 

175 name=self.metrics.name, 

176 doc=self.metrics.doc, 

177 storageClass=self.metrics.storageClass, 

178 dimensions=self.dimensions, 

179 multiple=False, 

180 isCalibration=False, 

181 ) 

182 else: 

183 # There are no metrics to produce, remove the output connection 

184 self.outputs.remove("metrics") 

185 

186 # Look for any conflicting names, creating a set of them, as these 

187 # will be added to the instance as well as recorded in the outputs 

188 # set. 

189 existingNames = set(dir(self)) 

190 

191 # Accumulate all the names to be used from all of the defined 

192 # AnalysisPlots. 

193 names: Mapping[str, AnalysisTool] = {} 

194 for action in config.atools: 

195 if not action.doProducePlots: 

196 continue 

197 if action.dynamicOutputNames: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 outNames = action.getOutputNames(config=config) 

199 else: 

200 outNames = action.getOutputNames() 

201 if action.parameterizedBand: 201 ↛ 205line 201 didn't jump to line 205 because the condition on line 201 was always true

202 for band in config.bands: 

203 names.update({name.format(band=band): action for name in outNames}) 

204 else: 

205 names.update({name: action for name in outNames}) 

206 

207 # For each of the names found, create output connections. 

208 for name, action in names.items(): 

209 name = f"{outputName}_{name}" 

210 if name in self.outputs or name in existingNames: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 raise NameError( 

212 f"Plot with name {name} conflicts with existing connection are two plots named the same?" 

213 ) 

214 

215 if action.parameterizedBand and "band" not in self.dimensions: 215 ↛ 231line 215 didn't jump to line 231 because the condition on line 215 was always true

216 # If band is in self.dimensions, but the bands still appear 

217 # in the output names, then it is likely a user error or the 

218 # user is intentional in having a pair-wise plot. So we will 

219 # let the band names pass through in the else block. To avoid 

220 # the band from appearing, parametrizedBand needs to be False. 

221 multiple = True 

222 dimensions = self.dimensions.union({"band"}) 

223 # Struct attributes have the structure 

224 # {outputName}_{band}_{name}. 

225 band_name = name.split(outputName, maxsplit=1)[1] # _{band}_{name}. 

226 # Limit the maximum split to 2 to avoid splitting the name 

227 # that may contain underscores. 

228 band, name = band_name.split("_", maxsplit=2)[1:] # ["", band, name]. 

229 name = f"{outputName}_{name}" 

230 else: 

231 multiple = False 

232 dimensions = self.dimensions 

233 

234 outConnection = ct.Output( 

235 name=name, 

236 storageClass="Plot", 

237 doc="Dynamic connection for plotting", 

238 dimensions=dimensions, 

239 multiple=multiple, 

240 ) 

241 setattr(self, name, outConnection) 

242 

243 def adjustQuantum( 

244 self, 

245 inputs: dict[str, tuple[ct.BaseInput, Collection[DatasetRef]]], 

246 outputs: dict[str, tuple[ct.Output, Collection[DatasetRef]]], 

247 label: str, 

248 data_id: DataCoordinate, 

249 ) -> tuple[ 

250 Mapping[str, tuple[ct.BaseInput, Collection[DatasetRef]]], 

251 Mapping[str, tuple[ct.Output, Collection[DatasetRef]]], 

252 ]: 

253 # If the task does not have band in its dimensions but an output 

254 # connection does, only keep the output refs that are consistent with 

255 # the producing AnalysisTool's 'bands' attribute. 

256 if "band" in data_id.dimensions.names: 

257 # Nothing to do, since the task dimensions already have band; just 

258 # delegate to super. Note that we use the data ID dimensions since 

259 # self.dimensions is not an (expanded) DimensionGroup. 

260 return super().adjustQuantum(inputs, outputs, label, data_id) 

261 adjusted_outputs: dict[str, tuple[ct.Output, list[DatasetRef]]] = {} 

262 for name, (connection, old_refs) in outputs.items(): 

263 # We also don't want to rely on connection.dimensions; the 

264 # dimensions in the refs are expanded and hence safer. 

265 new_refs: list[DatasetRef] | None = None 

266 for old_ref in old_refs: 

267 if new_refs is None: 

268 if "band" in old_ref.datasetType.dimensions: 

269 new_refs = [] 

270 else: 

271 # If this ref doesn't have 'band' none of the others 

272 # for this connection will either; move on to the next. 

273 break 

274 if old_ref.dataId["band"] in self.config.bands: 

275 new_refs.append(old_ref) 

276 if new_refs is not None: 

277 adjusted_outputs[name] = (connection, new_refs) 

278 # Update the original outputs so we can pass them to super. 

279 outputs.update(adjusted_outputs) 

280 super().adjustQuantum(inputs, outputs, label, data_id) 

281 # Return only the connections we modified. 

282 return {}, adjusted_outputs 

283 

284 

285def _timestampValidator(value: str) -> bool: 

286 if value in ("reference_package_timestamp", "run_timestamp", "current_timestamp", "dataset_timestamp"): 286 ↛ 288line 286 didn't jump to line 288 because the condition on line 286 was always true

287 return True 

288 elif "explicit_timestamp" in value: 

289 try: 

290 _, splitTime = value.split(":") 

291 except ValueError: 

292 logging.error( 

293 "Explicit timestamp must be given in the format 'explicit_timestamp:datetime', " 

294 r"where datetime is given in the form '%Y%m%dT%H%M%S%z" 

295 ) 

296 return False 

297 try: 

298 datetime.datetime.strptime(splitTime, r"%Y%m%dT%H%M%S%z") 

299 except ValueError: 

300 # This is explicitly chosen to be an f string as the string 

301 # contains control characters. 

302 logging.error( 

303 f"The supplied datetime {splitTime} could not be parsed correctly into " 

304 r"%Y%m%dT%H%M%S%z format" 

305 ) 

306 return False 

307 return True 

308 else: 

309 return False 

310 

311 

312class AnalysisBaseConfig(PipelineTaskConfig, pipelineConnections=AnalysisBaseConnections): 

313 """Base class for all configs used to define an `AnalysisPipelineTask`. 

314 

315 This base class defines two fields that should be used in all subclasses, 

316 atools, and bands. 

317 

318 The ``atools`` field is where the user configures which analysis tools will 

319 be run as part of this `PipelineTask`. 

320 

321 The bands field specifies which bands will be looped over for 

322 `AnalysisTools` which support parameterized bands. I.e. called once for 

323 each band in the list. 

324 """ 

325 

326 atools = ConfigurableActionStructField[AnalysisTool]( 

327 doc="The analysis tools that are to be run by this task at execution" 

328 ) 

329 # Temporarally alias these for backwards compatibility 

330 plots = atools 

331 metrics = atools 

332 bands = ListField[str]( 

333 doc="Filter bands on which to run all of the actions", default=["u", "g", "r", "i", "z", "y"] 

334 ) 

335 metric_tags = ListField[str]( 

336 doc="List of tags which will be added to all configurable actions", default=[] 

337 ) 

338 doProducePlots = Field[bool]( 

339 doc="Produce plot outputs for the atools configured on this " 

340 "task. Set to False to disable plot generation for all atools " 

341 "run by this task while still producing their metrics.", 

342 default=True, 

343 ) 

344 dataset_identifier = Field[str](doc="An identifier to be associated with output Metrics", optional=True) 

345 reference_package = Field[str]( 

346 doc="A package who's version, at the time of metric upload to a " 

347 "time series database, will be converted to a timestamp of when " 

348 "that version was produced", 

349 default="lsst_distrib", 

350 ) 

351 timestamp_version = Field[str]( 

352 doc="Which time stamp should be used as the reference timestamp for a " 

353 "metric in a time series database, valid values are; " 

354 "reference_package_timestamp, run_timestamp, current_timestamp, " 

355 "dataset_timestamp and explicit_timestamp:datetime where datetime is " 

356 "given in the form %Y%m%dT%H%M%S%z", 

357 default="run_timestamp", 

358 check=_timestampValidator, 

359 ) 

360 addOutputNamePrefix = Field[bool]( 

361 doc="If True, the connections class output name will be prefixed " 

362 "to the analysis 'atools' name when the value is dispatched to sasquatch.", 

363 default=False, 

364 ) 

365 

366 def applyConfigOverrides( 

367 self, 

368 instrument: Instrument | None, 

369 taskDefaultName: str, 

370 pipelineConfigs: Iterable[ConfigIR] | None, 

371 parameters: ParametersIR, 

372 label: str, 

373 ) -> None: 

374 extraConfig = {} 

375 if (value := parameters.mapping.get("sasquatch_dataset_identifier", None)) is not None: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 extraConfig["dataset_identifier"] = value 

377 if (value := parameters.mapping.get("sasquatch_reference_package", None)) is not None: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 extraConfig["reference_package"] = value 

379 if (value := parameters.mapping.get("sasquatch_timestamp_version", None)) is not None: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 if "explicit_timestamp" in value: 

381 try: 

382 _, splitTime = value.split(":") 

383 except ValueError as excpt: 

384 raise ValueError( 

385 "Explicit timestamp must be given in the format 'explicit_timestamp:datetime', " 

386 "where datetime is given in the form '%Y%m%dT%H%M%S%z" 

387 ) from excpt 

388 try: 

389 datetime.datetime.strptime(splitTime, r"%Y%m%dT%H%M%S%z") 

390 except ValueError as excpt: 

391 raise ValueError( 

392 f"The supplied datetime {splitTime} could not be parsed correctly into " 

393 "%Y%m%dT%H%M%S%z format" 

394 ) from excpt 

395 extraConfig["timestamp_version"] = value 

396 if extraConfig: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 newPipelineConfigs = [ConfigIR(rest=extraConfig)] 

398 if pipelineConfigs is not None: 

399 newPipelineConfigs.extend(pipelineConfigs) 

400 pipelineConfigs = newPipelineConfigs 

401 return super().applyConfigOverrides(instrument, taskDefaultName, pipelineConfigs, parameters, label) 

402 

403 def freeze(self): 

404 # Copy the meta configuration values to each of the configured tools 

405 # only do this if the tool has not been further specialized 

406 if not self._frozen: 406 ↛ 412line 406 didn't jump to line 412 because the condition on line 406 was always true

407 for tool in self.atools: 

408 for tag in self.metric_tags: 408 ↛ 409line 408 didn't jump to line 409 because the loop on line 408 never started

409 tool.metric_tags.insert(-1, tag) 

410 if not self.doProducePlots: 

411 tool.doProducePlots = False 

412 super().freeze() 

413 

414 def validate(self): 

415 super().validate() 

416 # Validate that the required connections template is set. 

417 if self.connections.outputName == "Placeholder": # type: ignore 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 raise RuntimeError("Connections class 'outputName' must have a config explicitly set") 

419 

420 

421class _StandinPlotInfo(dict): 

422 """This class is an implementation detail to support plots in the instance 

423 no PlotInfo object is present in the call to run. 

424 """ 

425 

426 def __missing__(self, key): 

427 return "" 

428 

429 

430class AnalysisPipelineTask(PipelineTask): 

431 """Base class for `PipelineTasks` intended to run `AnalysisTools`. 

432 

433 The run method will run all of the `AnalysisTools` defined in the config 

434 class. 

435 """ 

436 

437 # Typing config because type checkers dont know about our Task magic 

438 config: AnalysisBaseConfig 

439 ConfigClass = AnalysisBaseConfig 

440 

441 warnings_all = ( 

442 "divide by zero encountered in divide", 

443 "invalid value encountered in arcsin", 

444 "invalid value encountered in cos", 

445 "invalid value encountered in divide", 

446 "invalid value encountered in log10", 

447 "invalid value encountered in scalar divide", 

448 "invalid value encountered in sin", 

449 "invalid value encountered in sqrt", 

450 "invalid value encountered in true_divide", 

451 "Mean of empty slice", 

452 ) 

453 

454 def _runTools(self, data: KeyedData, **kwargs) -> Struct: 

455 caughtErrors = {} 

456 with warnings.catch_warnings(): 

457 # Change below to "in self.warnings_all" to find otherwise 

458 # unfiltered numpy warnings. 

459 for warning in (): 

460 warnings.filterwarnings("error", warning, RuntimeWarning) 

461 results = Struct() 

462 prefixName = f"{self.config.connections.outputName}_" if self.config.addOutputNamePrefix else "" 

463 results.metrics = MetricMeasurementBundle( 

464 dataset_identifier=self.config.dataset_identifier, 

465 reference_package=self.config.reference_package, 

466 timestamp_version=self.config.timestamp_version, 

467 metricNamePrefix=prefixName, 

468 ) 

469 # copy plot info to be sure each action sees its own copy 

470 plotInfo = kwargs.get("plotInfo") 

471 plotKey = f"{self.config.connections.outputName}_{{name}}" 

472 weakrefArgs = [] 

473 for name, action in self.config.atools.items(): 

474 kwargs["plotInfo"] = deepcopy(plotInfo) 

475 try: 

476 actionResult = action(data, **kwargs) 

477 except Exception: 

478 tb = traceback.format_exc() 

479 caughtErrors[name] = tb 

480 

481 continue 

482 metricAccumulate = [] 

483 for resultName, value in actionResult.items(): 

484 match value: 

485 case PlotTypes(): 

486 setattr(results, plotKey.format(name=resultName), value) 

487 weakrefArgs.append(value) 

488 case Measurement(): 

489 metricAccumulate.append(value) 

490 # only add the metrics if there are some 

491 if metricAccumulate: 

492 results.metrics[name] = metricAccumulate 

493 # Wrap the return struct in a finalizer so that when results is 

494 # garbage collected the plots will be closed. 

495 # TODO: This finalize step closes all open plots at the conclusion 

496 # of a task. When DM-39114 is implemented, this step should not 

497 # be required and may be removed. 

498 weakref.finalize(results, _plotCloser, *weakrefArgs) 

499 results.__caughtErrors = caughtErrors 

500 return results 

501 

502 def run(self, *, data: KeyedData | None = None, **kwargs) -> Struct: 

503 """Produce the outputs associated with this `PipelineTask`. 

504 

505 Parameters 

506 ---------- 

507 data : `KeyedData` 

508 The input data from which all `AnalysisTools` will run and produce 

509 outputs. A side note, the python typing specifies that this can be 

510 None, but this is only due to a limitation in python where in order 

511 to specify that all arguments be passed only as keywords the 

512 argument must be given a default. This argument most not actually 

513 be None. 

514 **kwargs 

515 Additional arguments that are passed through to the `AnalysisTools` 

516 specified in the configuration. 

517 

518 Returns 

519 ------- 

520 results : `~lsst.pipe.base.Struct` 

521 The accumulated results of all the plots and metrics produced by 

522 this `PipelineTask`. 

523 

524 Raises 

525 ------ 

526 ValueError 

527 Raised if the supplied data argument is `None` 

528 """ 

529 if data is None: 

530 raise ValueError("data must not be none") 

531 if "bands" not in kwargs: 

532 kwargs["bands"] = list(self.config.bands) 

533 if "plotInfo" not in kwargs: 

534 kwargs["plotInfo"] = _StandinPlotInfo() 

535 kwargs["plotInfo"]["bands"] = kwargs["bands"] 

536 return self._runTools(data, **kwargs) 

537 

538 def runQuantum( 

539 self, 

540 butlerQC: QuantumContext, 

541 inputRefs: InputQuantizedConnection, 

542 outputRefs: OutputQuantizedConnection, 

543 ) -> None: 

544 """Override default runQuantum to load the minimal columns necessary 

545 to complete the action. 

546 

547 Parameters 

548 ---------- 

549 butlerQC : `~lsst.pipe.base.QuantumContext` 

550 A butler which is specialized to operate in the context of a 

551 `lsst.daf.butler.Quantum`. 

552 inputRefs : `InputQuantizedConnection` 

553 Datastructure whose attribute names are the names that identify 

554 connections defined in corresponding `PipelineTaskConnections` 

555 class. The values of these attributes are the 

556 `lsst.daf.butler.DatasetRef` objects associated with the defined 

557 input/prerequisite connections. 

558 outputRefs : `OutputQuantizedConnection` 

559 Datastructure whose attribute names are the names that identify 

560 connections defined in corresponding `PipelineTaskConnections` 

561 class. The values of these attributes are the 

562 `lsst.daf.butler.DatasetRef` objects associated with the defined 

563 output connections. 

564 """ 

565 inputs = butlerQC.get(inputRefs) 

566 dataId = butlerQC.quantum.dataId 

567 plotInfo = self.parsePlotInfo(inputs, dataId) 

568 # We implicitly assume that 'data' has been defined, but do not have a 

569 # corresponding input connection in the base class. Thus, we capture 

570 # and re-raise the error with a more helpful message. 

571 try: 

572 # data has to be popped out to avoid duplication in the call to the 

573 # `run` method. 

574 inputData = inputs.pop("data") 

575 except KeyError: 

576 raise RuntimeError("'data' is a required input connection, but is not defined.") 

577 data = self.loadData(inputData) 

578 outputs = self.run(data=data, plotInfo=plotInfo, **inputs) 

579 self.putByBand(butlerQC, outputs, outputRefs) 

580 

581 def putByBand(self, butlerQC: QuantumContext, outputs: Struct, outputRefs: OutputQuantizedConnection): 

582 """Handle the outputs by band. 

583 

584 This is a convenience method to handle the case where the 

585 PipelineTaskConnection had to instantiate multiple output connections 

586 for plots to loop over bands. 

587 

588 Parameters 

589 ---------- 

590 butlerQC : `~lsst.pipe.base.QuantumContext` 

591 A butler which is specialized to operate in the context of a 

592 `lsst.daf.butler.Quantum`. 

593 outputs : `~lsst.pipe.base.Struct` 

594 The accumulated results of all the plots and metrics produced by 

595 the `run` method of this `PipelineTask`. 

596 outputRefs : `OutputQuantizedConnection` 

597 Datastructure whose attribute names are the names that identify 

598 connections defined in corresponding `PipelineTaskConnections` 

599 class. The values of these attributes are the 

600 `lsst.daf.butler.DatasetRef` objects associated with the defined 

601 output connections. 

602 """ 

603 for outputRefName in outputRefs.keys(): 

604 if outputRefName == "metrics": 

605 butlerQC.put(getattr(outputs, outputRefName), getattr(outputRefs, outputRefName)) 

606 continue 

607 

608 name = outputRefName.split(self.config.connections.outputName, maxsplit=1)[1] 

609 

610 datasetRef = getattr(outputRefs, outputRefName) 

611 if hasattr(datasetRef, "__iter__"): 

612 for outputRef in datasetRef: 

613 band = outputRef.dataId.get("band", "") 

614 # name would already have a leading underscore. 

615 newOutputName = f"{self.config.connections.outputName}_{band}{name}" 

616 if dataset := getattr(outputs, newOutputName, None): 

617 butlerQC.put(dataset, outputRef) 

618 else: 

619 if dataset := getattr(outputs, outputRefName, None): 

620 butlerQC.put(dataset, datasetRef) 

621 

622 # If any of the actions encountered errors, raise an 

623 # AnnotatedPartialOutputsError with the details. 

624 if outputs.__caughtErrors: 

625 e = PartialAtoolsFailureError(outputs.__caughtErrors) 

626 error = AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

627 raise error from e 

628 

629 def _populatePlotInfoWithDataId( 

630 self, plotInfo: MutableMapping[str, Any], dataId: DataCoordinate | None 

631 ) -> None: 

632 """Update the plotInfo with the dataId values. 

633 

634 Parameters 

635 ---------- 

636 plotInfo : `dict` 

637 The plotInfo dictionary to update. 

638 dataId : `lsst.daf.butler.DataCoordinate` 

639 The dataId to use to update the plotInfo. 

640 """ 

641 if dataId is not None: 641 ↛ exitline 641 didn't return from function '_populatePlotInfoWithDataId' because the condition on line 641 was always true

642 plotInfo.update(dataId.mapping) 

643 

644 def parsePlotInfo( 

645 self, inputs: Mapping[str, Any] | None, dataId: DataCoordinate | None, connectionName: str = "data" 

646 ) -> Mapping[str, str]: 

647 """Parse the inputs and dataId to get the information needed to 

648 to add to the figure. 

649 

650 Parameters 

651 ---------- 

652 inputs: `dict` 

653 The inputs to the task 

654 dataCoordinate: `lsst.daf.butler.DataCoordinate` 

655 The dataId that the task is being run on. 

656 connectionName: `str`, optional 

657 Name of the input connection to use for determining table name. 

658 

659 Returns 

660 ------- 

661 plotInfo : `dict` 

662 """ 

663 

664 if inputs is None: 

665 tableName = "" 

666 run = "" 

667 else: 

668 tableName = inputs[connectionName].ref.datasetType.name 

669 run = inputs[connectionName].ref.run 

670 

671 # Initialize the plot info dictionary 

672 plotInfo = {"tableName": tableName, "run": run} 

673 

674 self._populatePlotInfoWithDataId(plotInfo, dataId) 

675 return plotInfo 

676 

677 def loadData(self, handle: DeferredDatasetHandle, names: Iterable[str] | None = None) -> KeyedData: 

678 """Load the minimal set of keyed data from the input dataset. 

679 

680 Parameters 

681 ---------- 

682 handle : `DeferredDatasetHandle` 

683 Handle to load the dataset with only the specified columns. 

684 names : `Iterable` of `str` 

685 The names of keys to extract from the dataset. 

686 If `names` is `None` then the `collectInputNames` method 

687 is called to generate the names. 

688 For most purposes these are the names of columns to load from 

689 a catalog or data frame. 

690 

691 Returns 

692 ------- 

693 result: `KeyedData` 

694 The dataset with only the specified keys loaded. 

695 """ 

696 if names is None: 

697 names = self.collectInputNames() 

698 return cast(KeyedData, handle.get(parameters={"columns": names})) 

699 

700 def collectInputNames(self) -> Iterable[str]: 

701 """Get the names of the inputs. 

702 

703 If using the default `loadData` method this will gather the names 

704 of the keys to be loaded from an input dataset. 

705 

706 Returns 

707 ------- 

708 inputs : `Iterable` of `str` 

709 The names of the keys in the `KeyedData` object to extract. 

710 

711 """ 

712 inputs = set() 

713 

714 if not (localBands := self.config.bands): 

715 localBands = [""] 

716 for band in localBands: 

717 for action in self.config.atools: 

718 for key, _ in action.getFormattedInputSchema(band=band): 

719 inputs.add(key) 

720 return inputs