Coverage for python/lsst/analysis/tools/atools/diffMatched.py: 72%

552 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-25 09:29 +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__ = ( 

24 "MatchedRefCoaddTool", 

25 "MatchedRefCoaddChiColorTool", 

26 "MatchedRefCoaddChiCoordDecTool", 

27 "MatchedRefCoaddChiCoordRaTool", 

28 "MatchedRefCoaddChiDistanceTool", 

29 "MatchedRefCoaddChiMagTool", 

30 "MatchedRefCoaddCompurityTool", 

31 "MatchedRefCoaddDiffColorTool", 

32 "MatchedRefCoaddDiffColorZoomTool", 

33 "MatchedRefCoaddDiffCoordDecTool", 

34 "MatchedRefCoaddDiffCoordDecZoomTool", 

35 "MatchedRefCoaddDiffCoordRaTool", 

36 "MatchedRefCoaddDiffCoordRaZoomTool", 

37 "MatchedRefCoaddDiffDistanceTool", 

38 "MatchedRefCoaddDiffMagTool", 

39 "MatchedRefCoaddDiffMagZoomTool", 

40 "MatchedRefCoaddDiffPositionTool", 

41 "MatchedRefCoaddDiffTool", 

42 "MatchedRefCoaddDiffDistanceZoomTool", 

43 "reconfigure_diff_matched_defaults", 

44) 

45 

46import copy 

47import inspect 

48from abc import abstractmethod 

49 

50import astropy.units as u 

51 

52import lsst.pex.config as pexConfig 

53from lsst.pex.config import DictField, Field 

54from lsst.pex.config.configurableActions import ConfigurableActionField 

55from lsst.utils.plotting.publication_plots import galaxies_color, stars_color 

56 

57from ..actions.config import MagnitudeBinConfig 

58from ..actions.keyedData import ( 

59 CalcBinnedCompletenessAction, 

60 CalcCompletenessHistogramAction, 

61 MagnitudeCompletenessConfig, 

62) 

63from ..actions.plot import CompletenessHist 

64from ..actions.vector import ( 

65 CalcBinnedStatsAction, 

66 ColorDiff, 

67 ColorError, 

68 ConstantValue, 

69 CosVector, 

70 DivideVector, 

71 DownselectVector, 

72 IsMatchedObjectSameClass, 

73 LoadVector, 

74 MultiplyVector, 

75 SubtractVector, 

76) 

77from ..actions.vector.selectors import ( 

78 InjectedGalaxySelector, 

79 InjectedObjectSelector, 

80 InjectedStarSelector, 

81 MatchedObjectSelector, 

82 RangeSelector, 

83 ReferenceGalaxySelector, 

84 ReferenceObjectSelector, 

85 ReferenceStarSelector, 

86 SelectorBase, 

87 VectorSelector, 

88) 

89from ..interfaces import AnalysisBaseConfig, BaseMetricAction, NoMetric 

90from .genericBuild import MagnitudeTool, MagnitudeXTool, ObjectClassTool 

91from .genericMetricAction import StructMetricAction 

92from .genericPlotAction import StructPlotAction 

93from .genericProduce import MagnitudeScatterPlot 

94 

95 

96def _set_field_config(config: pexConfig.Config | pexConfig.ConfigMeta, name: str, value): 

97 """Set the value of a Config Field or ConfigMeta default value. 

98 

99 Parameters 

100 ---------- 

101 config 

102 A Config instance or metaclass. 

103 name 

104 The name of the attribute to set. 

105 value 

106 The value to set it to. 

107 """ 

108 if isinstance(config, pexConfig.ConfigMeta): 

109 getattr(config, name).default = value 

110 else: 

111 setattr(config, name, value) 

112 

113 

114class MatchedRefCoaddTool(ObjectClassTool): 

115 """Base tool for matched-to-reference metrics/plots on coadds. 

116 

117 This tool is designed to configure plots and metrics as a function of 

118 magnitude (object or reference). The metrics are binned by the same 

119 magnitude shown on the x-axis in plots. By default, this is the reference 

120 magnitude but plots can be configured to bin by object magnitude instead. 

121 

122 Notes 

123 ----- 

124 The tool does not use a standard coadd flag selector, because 

125 it is expected that the matcher has been configured to select 

126 appropriate candidates (and stores a match_candidate column). 

127 

128 The tool requires specification of reference galaxy and star selectors, 

129 as these will be used to determine whether matched objects have the same 

130 class as the reference, even if a particular class is not being plotted. 

131 It is okay to specify a "dummy" selector that always returns False if 

132 there are no reference objects of the given class. 

133 """ 

134 

135 _suffix_ref = "_ref" 

136 _suffix_target = "_target" 

137 

138 context = pexConfig.ChoiceField[str]( 

139 doc="The context for the selectors", 

140 allowed={ 

141 "custom": "User-configured selectors", 

142 "DC2": "DC2 Truth Summary match", 

143 "injection": "Source injection match", 

144 }, 

145 default="DC2", 

146 ) 

147 

148 select_ref_by_default = pexConfig.Field[bool]( 

149 doc="Whether reference quantities should be used by default in other tools," 

150 " e.g. for binning metrics and for the x-axis in plots", 

151 default=True, 

152 ) 

153 

154 selector_ref_all = ConfigurableActionField[SelectorBase]( 

155 doc="The selector for reference objects of all types", 

156 default=ReferenceObjectSelector, 

157 ) 

158 selector_ref_galaxy = ConfigurableActionField[SelectorBase]( 

159 doc="The selector for reference galaxies", 

160 default=ReferenceGalaxySelector, 

161 ) 

162 selector_ref_star = ConfigurableActionField[SelectorBase]( 

163 doc="The selector for reference stars", 

164 default=ReferenceStarSelector, 

165 ) 

166 

167 mag_bins = pexConfig.ConfigField[MagnitudeBinConfig](doc="Magnitude bin configuration for metrics") 

168 # These are optional because validate can be called before finalize 

169 # Validate should not fail in that case if it would otherwise succeed 

170 name_prefix = pexConfig.Field[str]( 

171 doc="Default prefix for metric key. Can include {name_type} as a" 

172 " template for the type of object (resolved/unresolved)", 

173 default=None, 

174 optional=True, 

175 ) 

176 name_suffix = pexConfig.Field[str]( 

177 doc="The suffix for metric names. Can include {name_mag} as a " 

178 " template for the magnitude algorithm", 

179 default="_ref_mag{name_mag}", 

180 ) 

181 unit = pexConfig.Field[str](doc="Astropy unit of y-axis values", default=None, optional=True) 

182 

183 def finalize(self): 

184 # Don't do anything if the value is the one for which the defaults of 

185 # selector_ref_all, etc are - this can't easily be inferred and must 

186 # be kept in sync manually 

187 if self.context != "DC2": 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 match self.context: 

189 case "injection": 

190 self.selector_ref_all = InjectedObjectSelector() 

191 self.selector_ref_galaxy = InjectedGalaxySelector() 

192 self.selector_ref_star = InjectedStarSelector() 

193 case "custom": 

194 pass 

195 case _: 

196 raise NotImplementedError(f"{self.context=} is not implemented in {self.__class__}") 

197 

198 # Other tools will except selector_all 

199 self.selection_suffix = self._suffix_ref if self.select_ref_by_default else self._suffix_target 

200 

201 super().finalize() 

202 

203 for object_class in self.get_classes(): 

204 name_selector = self.get_name_attr_selector(object_class, self._suffix_ref) 

205 selector = self.get_selector_ref(object_class) 

206 # This is a build action because selectors in prep are applied with 

207 # and; we're not using these to filter all points but to make 

208 # several parallel selections 

209 setattr(self.process.buildActions, name_selector, selector) 

210 

211 def get_selector_ref(self, object_class: str): 

212 match object_class: 

213 case "any": 

214 return self.selector_ref_all 

215 case "galaxy": 

216 return self.selector_ref_galaxy 

217 case "star": 217 ↛ exitline 217 didn't return from function 'get_selector_ref' because the pattern on line 217 always matched

218 return self.selector_ref_star 

219 

220 def reconfigure( 

221 self, 

222 context: str | None = None, 

223 key_flux_meas: str | None = None, 

224 bands_color: dict[str, str] | list[str] | None = None, 

225 use_any: bool | None = None, 

226 use_galaxies: bool | None = None, 

227 use_stars: bool | None = None, 

228 ): 

229 """Reconfigure any MatchedRefCoaddTools in an analysis task config. 

230 

231 Parameters 

232 ---------- 

233 context 

234 The context to set. Must be a valid choice for 

235 MatchedRefCoaddTool.context. 

236 key_flux_meas 

237 The key of the measured flux config to use, e.g. "psf". If the key 

238 is not found, it will search for f"{key}_err", the default name for 

239 configurations that load error keys as well as fluxes. 

240 bands_color 

241 A dictionary keyed by band of comma-separated bands to measure 

242 colors for, where the color is (key - value). If a list is passed, 

243 tools will modify the defaults to select only those bands within 

244 the list (which should also be a set). 

245 use_any 

246 Whether to compute metrics for objects of all types. 

247 use_galaxies 

248 Whether to compute metrics and plot lines for galaxies only. 

249 use_stars 

250 Whether to compute metrics and plot lines for stars only. 

251 

252 Notes 

253 ----- 

254 Any kwargs set to None will not change the relevant config fields. 

255 """ 

256 if context is not None: 

257 _set_field_config(self, name="context", value=context) 

258 if use_any is not None: 

259 _set_field_config(self, name="use_any", value=use_any) 

260 if use_galaxies is not None: 

261 _set_field_config(self, name="use_galaxies", value=use_galaxies) 

262 if use_stars is not None: 

263 _set_field_config(self, name="use_stars", value=use_stars) 

264 

265 # This allows the method to work automatically on class defaults 

266 kwargs = {"self": self} if inspect.isclass(self) else {} 

267 

268 # Change any dependent magnitudes 

269 self.reconfigure_dependent_magnitudes(key_flux_meas=key_flux_meas, bands_color=bands_color, **kwargs) 

270 

271 def reconfigure_dependent_magnitudes( 

272 self, 

273 key_flux_meas: str | None = None, 

274 bands_color: dict[str, str] | list[str] | None = None, 

275 ): 

276 """Reconfigure any dependent (i.e., on the y-axis in plots) magnitude 

277 column configs. 

278 

279 Parameters 

280 ---------- 

281 key_flux_meas 

282 The key of the measured flux config to set to. 

283 bands_color 

284 A dictionary keyed by band of comma-separated bands to measure 

285 colors for, where the color is (key - value). If a list is passed, 

286 tools will modify the defaults to select only those bands within 

287 the list (which should also be a set). 

288 """ 

289 

290 def setDefaults(self): 

291 super().setDefaults() 

292 # The selection info isn't useful in plots with multiple classes 

293 self.selector_ref_galaxy.plotLabelKey = None 

294 self.selector_ref_star.plotLabelKey = None 

295 

296 

297class MatchedRefCoaddDiffTool(MagnitudeXTool, MatchedRefCoaddTool): 

298 """Base tool for generic diffs between reference and measured values.""" 

299 

300 limits_chi_default = (-5, 5) 

301 limits_diff_color_mmag_default = (-250.0, 250.0) 

302 limits_diff_color_mmag_zoom_default = (-50.0, 50.0) 

303 limits_diff_mag_mmag_default = (-1000.0, 1000.0) 

304 limits_diff_mag_mmag_zoom_default = (-50.0, 50.0) 

305 limits_diff_pos_mas_default = (-500, 500) 

306 limits_diff_pos_mas_zoom_default = (-10, 10) 

307 limits_x_mag_default = (16.5, 29.0) 

308 limits_x_mag_zoom_default = (16.5, 24.0) 

309 

310 compute_chi = pexConfig.Field[bool]( 

311 default=False, 

312 doc="Whether to compute scaled flux residuals (chi) instead of magnitude differences", 

313 ) 

314 

315 def _set_actions(self, suffix=None): 

316 if suffix is None: 

317 suffix = "" 

318 

319 selection = self._suffix_ref if self.select_ref_by_default else self._suffix_target 

320 for object_class in self.get_classes(): 

321 name_type_plural = self.get_class_name_plural(object_class) 

322 name_attr = f"{self.get_name_attr_values(object_class)}{suffix}" 

323 name_selector = self.get_name_attr_selector(object_class, selection) 

324 name_x = f"x{name_type_plural.capitalize()}" 

325 

326 y_values = DownselectVector( 

327 vectorKey=f"diff{suffix}", 

328 selector=VectorSelector(vectorKey=name_selector), 

329 ) 

330 setattr(self.process.filterActions, name_attr, y_values) 

331 

332 bins = self.mag_bins.get_bins() 

333 for minimum in bins: 

334 setattr( 

335 self.process.calculateActions, 

336 f"{name_type_plural}_{minimum}{suffix}", 

337 CalcBinnedStatsAction( 

338 key_vector=name_attr, 

339 selector_range=RangeSelector( 

340 vectorKey=name_x, 

341 minimum=minimum, 

342 maximum=minimum + self.mag_bins.mag_width, 

343 ), 

344 ), 

345 ) 

346 

347 def configureMetrics( 

348 self, 

349 unit: str | None = None, 

350 name_prefix: str | None = None, 

351 attr_suffix: str | None = None, 

352 unit_select: str = "mag", 

353 ): 

354 """Configure metric actions and return units. 

355 

356 Parameters 

357 ---------- 

358 unit : `str` 

359 The (astropy) unit of the summary statistic metrics. 

360 name_prefix : `str` 

361 The prefix for the action (column) name. 

362 attr_suffix : `str` 

363 The suffix for the attribute to assign the action to. 

364 unit_select : `str` 

365 The (astropy) unit of the selection (x-axis) column. Default "mag". 

366 

367 Returns 

368 ------- 

369 units : `dict` [`str`, `str`] 

370 A dict of the unit (value) for each metric name (key) 

371 """ 

372 if unit is None: 372 ↛ 374line 372 didn't jump to line 374 because the condition on line 372 was always true

373 unit = self.unit if self.unit is not None else "" 

374 if name_prefix is None: 374 ↛ 376line 374 didn't jump to line 376 because the condition on line 374 was always true

375 name_prefix = self.name_prefix if self.name_prefix is not None else "" 

376 if attr_suffix is None: 

377 attr_suffix = "" 

378 

379 if unit_select is None: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 unit_select = "mag" 

381 

382 key_flux = self.config_mag_x.key_flux 

383 

384 units = {} 

385 

386 for object_class in self.get_classes(): 

387 name_type = self.get_class_type(object_class) 

388 name_type_plural = self.get_class_name_plural(object_class) 

389 name_capital = name_type_plural.capitalize() 

390 x_key = f"x{name_capital}" 

391 

392 # Set up metrics for objects of one class within a magnitude range 

393 bins = self.mag_bins.get_bins() 

394 for minimum in bins: 

395 action = getattr(self.process.calculateActions, f"{name_type_plural}_{minimum}{attr_suffix}") 

396 action.selector_range = RangeSelector( 

397 vectorKey=x_key, 

398 minimum=minimum / 1000.0, 

399 maximum=(minimum + self.mag_bins.mag_width) / 1000.0, 

400 ) 

401 name_mag = self.mag_bins.get_name_bin(minimum) 

402 

403 action.name_prefix = name_prefix.format( 

404 key_flux=key_flux, 

405 name_type=name_type, 

406 ) 

407 if self.parameterizedBand: 407 ↛ 409line 407 didn't jump to line 409 because the condition on line 407 was always true

408 action.name_prefix = f"{{band}}_{action.name_prefix}" 

409 action.name_suffix = self.name_suffix.format(name_mag=name_mag) 

410 

411 units.update( 

412 { 

413 action.name_median: unit, 

414 action.name_sigmaMad: unit, 

415 action.name_count: "count", 

416 action.name_select_median: unit_select, 

417 } 

418 ) 

419 return units 

420 

421 @property 

422 def config_mag_y(self): 

423 """Return the y-axis magnitude config. 

424 

425 Although tools may not end up using any flux measures in metrics or 

426 plots, this should still be set to the flux measure that was matched 

427 or selected against in the catalog not used for the x-axis.""" 

428 mag_y = self.get_key_flux_y() 

429 if mag_y not in self.fluxes: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 raise KeyError(f"{mag_y=} not in {self.fluxes}; was finalize called?") 

431 # This is a logic error: it shouldn't be called before finalize 

432 assert mag_y in self.fluxes 

433 return self.fluxes[mag_y] 

434 

435 def finalize(self): 

436 MagnitudeXTool.finalize(self) 

437 MatchedRefCoaddTool.finalize(self) 

438 

439 @abstractmethod 

440 def get_key_flux_y(self) -> str: 

441 """Return the key for the y-axis flux measure.""" 

442 raise NotImplementedError("subclasses must implement get_key_flux_y") 

443 

444 def setDefaults(self): 

445 MagnitudeXTool.setDefaults(self) 

446 MatchedRefCoaddTool.setDefaults(self) 

447 self.mag_x = "ref_matched" 

448 self.prep.selectors.matched = MatchedObjectSelector() 

449 

450 

451class MatchedRefCoaddDiffPlot(MatchedRefCoaddDiffTool, MagnitudeScatterPlot): 

452 """Base tool for generic diffs between reference and measured values, 

453 with a scatter plot.""" 

454 

455 def do_metrics(self): 

456 return not isinstance(self.produce.metric, NoMetric) 

457 

458 def get_key_flux_y(self) -> str: 

459 return super().get_key_flux_y() 

460 

461 def finalize(self): 

462 MatchedRefCoaddDiffTool.finalize(self) 

463 MagnitudeScatterPlot.finalize(self) 

464 

465 def setDefaults(self): 

466 # This will set no plot 

467 MatchedRefCoaddDiffTool.setDefaults(self) 

468 # This will set the plot 

469 MagnitudeScatterPlot.setDefaults(self) 

470 self.produce.plot.xLims = self.limits_x_mag_default 

471 

472 

473class MatchedRefCoaddCompurityTool(MagnitudeTool, MatchedRefCoaddTool): 

474 """Plot the fraction of injected sources recovered by input magnitude. 

475 

476 By contrast with MatchedRefCoaddDiffTool, where one must choose which 

477 magnitude appears on the x-axis, this tools creates two plots with 

478 different magnitudes. The completeness plot necessarily is a function 

479 of reference magnitude while purity is a function of object (target) 

480 magnitude. 

481 """ 

482 

483 config_metrics = pexConfig.ConfigField[MagnitudeCompletenessConfig]( 

484 doc="Plot-based (unbinned) metric definition configuration" 

485 ) 

486 key_match_distance = pexConfig.Field[str]( 

487 default="match_distance", 

488 doc="Key for match distance column (>=0 for a successful match)", 

489 ) 

490 mag_bins_plot = pexConfig.ConfigField[MagnitudeBinConfig]( 

491 doc="Magnitude bin configuration for plots and for unbinned metrics" 

492 "(including completeness at magnitude thresholds)" 

493 ) 

494 mag_ref = pexConfig.Field[str]( 

495 default="ref_matched", 

496 doc="Flux (magnitude) config key (to self.fluxes) for reference (true) magnitudes", 

497 ) 

498 mag_target = pexConfig.Field[str]( 

499 default="cmodel_err", 

500 doc="Flux (magnitude) config key (to self.fluxes) for target (measured) magnitudes", 

501 ) 

502 make_plots = pexConfig.Field[bool]( 

503 default=True, 

504 doc="Whether to generate plots in addition to metrics", 

505 ) 

506 

507 @property 

508 def config_mag_ref(self): 

509 return self._config_mag("mag_ref") 

510 

511 @property 

512 def config_mag_target(self): 

513 return self._config_mag("mag_target") 

514 

515 def finalize(self): 

516 if not self.produce.metric.units: 516 ↛ exitline 516 didn't return from function 'finalize' because the condition on line 516 was always true

517 MagnitudeTool.finalize(self) 

518 MatchedRefCoaddTool.finalize(self) 

519 self._set_flux_default("mag_ref") 

520 self._set_flux_default("mag_target") 

521 

522 # This is the default convention for metric names, originally set 

523 # for DC2 truth match but expanded to generic reference catalogs 

524 # (including injection catalogs) 

525 name_prefix = ( 

526 self.name_prefix 

527 if self.name_prefix 

528 else ( 

529 f"detect_{self.config_mag_target.name_flux_short}_vs_" 

530 f"{self.config_mag_ref.name_flux_short}_{{name_type}}_" 

531 ) 

532 ) 

533 unit_select = "" 

534 kwargs_matched_class_action = {} 

535 

536 # Set up selectors for all object classes as they may be needed by 

537 # the wrong/right matched class selector 

538 for object_class in ("any", "galaxy", "star"): 

539 for suffix, func_selector in ( 

540 (self._suffix_ref, self.get_selector_ref), 

541 (self._suffix_target, self.get_selector), 

542 ): 

543 name_selector = self.get_name_attr_selector(object_class, suffix) 

544 if not hasattr(self.process.buildActions, name_selector): 

545 selector = func_selector(object_class) 

546 setattr(self.process.buildActions, name_selector, selector) 

547 if object_class != "any": 

548 kwargs_matched_class_action[f"key_is{suffix}_{object_class}"] = name_selector 

549 

550 # This isn't exactly a filterAction but by default it needs to go 

551 # after build and before calc, so here it is 

552 self.process.filterActions.matched_class = IsMatchedObjectSameClass(**kwargs_matched_class_action) 

553 

554 key_flux = self.config_mag_ref.key_flux 

555 key_mag_ref = f"mag_{self.mag_ref}" 

556 key_mag_target = f"mag_{self.mag_target}" 

557 object_classes = self.get_classes() 

558 self.produce.metric = StructMetricAction() 

559 if self.make_plots: 559 ↛ 562line 559 didn't jump to line 562 because the condition on line 559 was always true

560 self.produce.plot = StructPlotAction() 

561 

562 for object_class in object_classes: 

563 name_type = self.get_class_type(object_class) 

564 name_selector_ref = self.get_name_attr_selector(object_class, self._suffix_ref) 

565 name_selector_target = self.get_name_attr_selector(object_class, self._suffix_target) 

566 name_prefix_class = name_prefix.format( 

567 key_flux=key_flux, 

568 name_type=name_type, 

569 ) 

570 if self.parameterizedBand: 570 ↛ 573line 570 didn't jump to line 573 because the condition on line 570 was always true

571 name_prefix_class = f"{{band}}_{name_prefix_class}" 

572 

573 units = {} 

574 completeness_binned_metrics = CalcCompletenessHistogramAction( 

575 action=CalcBinnedCompletenessAction( 

576 name_prefix=name_prefix_class, 

577 selector_range_ref=RangeSelector(vectorKey=key_mag_ref), 

578 selector_range_target=RangeSelector(vectorKey=key_mag_target), 

579 key_mask_ref=name_selector_ref, 

580 key_mask_target=name_selector_target, 

581 ), 

582 bins=self.mag_bins, 

583 ) 

584 # Metric bins should be coarser than plot bins and therefore 

585 # are unsuited for computing unbinned metrics (like mag at a 

586 # given completeness/purity) 

587 completeness_binned_metrics.config_metrics.completeness_percentiles = [] 

588 setattr( 

589 self.process.calculateActions, 

590 f"completeness_binned_metrics_{object_class}", 

591 completeness_binned_metrics, 

592 ) 

593 

594 bins = self.mag_bins.get_bins() 

595 for minimum in bins: 

596 name_mag = self.mag_bins.get_name_bin(minimum) 

597 action = CalcBinnedCompletenessAction( 

598 name_prefix=name_prefix_class, 

599 name_suffix=self.name_suffix.format(name_mag=name_mag), 

600 selector_range_ref=RangeSelector( 

601 vectorKey=key_mag_ref, 

602 minimum=minimum / 1000.0, 

603 maximum=(minimum + self.mag_bins.mag_width) / 1000.0, 

604 ), 

605 selector_range_target=RangeSelector( 

606 vectorKey=key_mag_target, 

607 minimum=minimum / 1000.0, 

608 maximum=(minimum + self.mag_bins.mag_width) / 1000.0, 

609 ), 

610 key_mask_ref=name_selector_ref, 

611 key_mask_target=name_selector_target, 

612 ) 

613 setattr( 

614 self.process.calculateActions, 

615 f"completeness_{object_class}_{minimum}", 

616 action, 

617 ) 

618 

619 units.update( 

620 { 

621 action.name_count: "count", 

622 action.name_count_ref: "count", 

623 action.name_count_target: "count", 

624 action.name_completeness: unit_select, 

625 action.name_completeness_bad_match: unit_select, 

626 action.name_completeness_good_match: unit_select, 

627 action.name_purity: unit_select, 

628 action.name_purity_bad_match: unit_select, 

629 action.name_purity_good_match: unit_select, 

630 } 

631 ) 

632 

633 completeness_plot = CalcCompletenessHistogramAction( 

634 action=CalcBinnedCompletenessAction( 

635 name_prefix=name_prefix_class, 

636 selector_range_ref=RangeSelector(vectorKey=key_mag_ref), 

637 selector_range_target=RangeSelector(vectorKey=key_mag_target), 

638 key_mask_ref=name_selector_ref, 

639 key_mask_target=name_selector_target, 

640 ), 

641 bins=self.mag_bins_plot, 

642 config_metrics=self.config_metrics, 

643 ) 

644 setattr( 

645 self.process.calculateActions, 

646 f"completeness_plot_{object_class}", 

647 completeness_plot, 

648 ) 

649 for pct in completeness_plot.config_metrics.completeness_percentiles: 

650 name_pct = completeness_plot.action.name_mag_completeness( 

651 completeness_plot.getPercentileName(pct) 

652 ) 

653 units[name_pct] = unit_select 

654 

655 # Make the metric action for the given object class 

656 # This will include units for metrics from the plot histogram 

657 # (i.e. the magnitude for a given completeness threshold) 

658 setattr( 

659 self.produce.metric.actions, 

660 object_class, 

661 BaseMetricAction(units=units), 

662 ) 

663 

664 if self.make_plots: 664 ↛ 562line 664 didn't jump to line 562 because the condition on line 664 was always true

665 overrides = {} 

666 if name_type == self.type_galaxies: 

667 overrides["color_counts"] = galaxies_color() 

668 elif name_type == self.type_stars: 

669 overrides["color_counts"] = stars_color() 

670 plot_action = CompletenessHist( 

671 action=completeness_plot, 

672 ) 

673 # Since the plot action is made on the fly, it can't be 

674 # configured in a pipeline yaml. This makes the keys 

675 # formattable, but (unfortunately) in a way that's hard 

676 # to discover or document. 

677 for label_type in ("ref", "target"): 

678 name_mag_label = f"mag_{label_type}_label" 

679 label = getattr(plot_action, name_mag_label) 

680 if "{name_flux}" in label: 680 ↛ 677line 680 didn't jump to line 677 because the condition on line 680 was always true

681 name_flux = getattr(self, f"config_mag_{label_type}").name_flux 

682 # Still no support for partial formatting, i.e. 

683 # if {band} is in label and it's not a kwarg of 

684 # format, it will raise a KeyError 

685 kwargs_format = {"band": "{band}"} if "{band}" in label else {} 

686 label = label.format(name_flux=name_flux, **kwargs_format) 

687 setattr(plot_action, name_mag_label, label) 

688 setattr( 

689 self.produce.plot.actions, 

690 object_class, 

691 plot_action, 

692 ) 

693 

694 def reconfigure_dependent_magnitudes( 

695 self, 

696 key_flux_meas: str | None = None, 

697 bands_color: dict[str, str] | list[str] | None = None, 

698 ): 

699 if key_flux_meas is not None: 

700 _set_field_config(self, name="mag_target", value=key_flux_meas) 

701 

702 def setDefaults(self): 

703 MagnitudeTool.setDefaults(self) 

704 MatchedRefCoaddTool.setDefaults(self) 

705 

706 self.mag_bins_plot.mag_interval = 100 

707 self.mag_bins_plot.mag_width = 200 

708 # Completeness/purity don't need a ref/target suffix as they are by 

709 # definition a function of ref/target mags, respectively 

710 self.name_suffix = "_mag{name_mag}" 

711 

712 

713class MatchedRefCoaddDiffColorTool(MatchedRefCoaddDiffPlot): 

714 """Tool for diffs between reference and measured coadd mags. 

715 

716 Notes 

717 ----- 

718 Since this tool requires at least two bands, it is essentially impossible 

719 to call on its own. 

720 """ 

721 

722 mag_y1 = Field[str](default="cmodel_err", doc="Flux field for first magnitude") 

723 mag_y2 = Field[str]( 

724 doc="Flux field for second magnitude (to subtract from first); default same as first", 

725 default=None, 

726 optional=True, 

727 ) 

728 bands = DictField[str, str]( 

729 doc="Bands for colors. ", 

730 # The empty value for y is needed to indicate that it's a valid band 

731 default={"u": "g", "g": "r,i", "r": "i", "i": "z", "z": "y", "y": ""}, 

732 ) 

733 band_separator = Field[str](default=",", doc="Separator for multiple bands") 

734 

735 def _split_bands(self, band_list: str): 

736 # Split returns [""] for an empty string 

737 return band_list.split(self.band_separator) if band_list else [] 

738 

739 def finalize(self): 

740 # Check if it has already been finalized 

741 if not hasattr(self.process.buildActions, "diff_0"): 741 ↛ exitline 741 didn't return from function 'finalize' because the condition on line 741 was always true

742 if self.mag_y2 is None: 742 ↛ 743line 742 didn't jump to line 743 because the condition on line 742 was never true

743 self.mag_y2 = self.mag_y1 

744 # Ensure mag_y1/2 are set before any plot finalizes 

745 # This may result in duplicate actions but these are just plain 

746 # column selectors so that's not a serious problem 

747 bands = {band1: self._split_bands(band2_list) for band1, band2_list in self.bands.items()} 

748 n_bands = 0 

749 

750 # Set up mag actions for every band needed before finalizing plots 

751 for band1, band2_list in bands.items(): 

752 for band2 in band2_list: 

753 mag_y1 = f"mag_y_{band1}" 

754 mag_y2 = f"mag_y_{band2}" 

755 mag_x1 = f"mag_x_{band1}" 

756 mag_x2 = f"mag_x_{band2}" 

757 self._set_flux_default(mag_y1, band=band1, name_mag=self.mag_y1) 

758 self._set_flux_default(mag_y2, band=band2, name_mag=self.mag_y2) 

759 self._set_flux_default(mag_x1, band=band1, name_mag=self.mag_x) 

760 self._set_flux_default(mag_x2, band=band2, name_mag=self.mag_x) 

761 n_bands += 1 

762 

763 # These two lines must appear in this order so that every color 

764 # has its plot actions finalized with a suffix (i.e., pointing 

765 # summary stats at yStars_0 instead of yStars). 

766 self.suffixes_y_finalize = [f"_{idx}" for idx in range(n_bands)] 

767 super().finalize() 

768 

769 self.unit = "" if self.compute_chi else "mmag" 

770 subtype = "chi" if self.compute_chi else "diff" 

771 

772 metric_base = self.produce.metric 

773 metric = metric_base 

774 plot_base = self.produce.plot 

775 

776 do_metrics = self.do_metrics() 

777 

778 actions_metric = {} 

779 actions_plot = {} 

780 

781 config_mag_x = self.config_mag_x 

782 config_mag_y = self.config_mag_y 

783 name_short_x = config_mag_x.name_flux_short 

784 name_short_y = config_mag_y.name_flux_short 

785 

786 idx = 0 

787 for band1, band2_list in bands.items(): 

788 for band2 in band2_list: 

789 name_color = f"{band1}_minus_{band2}" 

790 # Keep this index-based to simplify finalize 

791 suffix_y = f"_{idx}" 

792 self._set_actions(suffix=suffix_y) 

793 self.name_prefix = ( 

794 f"photom_{name_short_y}_vs_{name_short_x}_color_{name_color}" 

795 f"_{subtype}_{{name_type}}_" 

796 ) 

797 if do_metrics: 797 ↛ 801line 797 didn't jump to line 801 because the condition on line 797 was always true

798 metric = copy.copy(metric_base) 

799 metric.units = self.configureMetrics(attr_suffix=suffix_y) 

800 

801 plot = copy.copy(plot_base) 

802 

803 plot.suffix_y = suffix_y 

804 plot.suffix_stat = suffix_y 

805 

806 mag_y1 = f"{self.mag_y1}_{band1}" 

807 mag_y2 = f"{self.mag_y2}_{band2}" 

808 mag_x1 = f"{self.mag_x}_{band1}" 

809 mag_x2 = f"{self.mag_x}_{band2}" 

810 

811 diff = ColorDiff( 

812 color1_flux1=getattr(self.process.buildActions, f"flux_{mag_y1}"), 

813 color1_flux2=getattr(self.process.buildActions, f"flux_{mag_y2}"), 

814 color2_flux1=getattr(self.process.buildActions, f"flux_{mag_x1}"), 

815 color2_flux2=getattr(self.process.buildActions, f"flux_{mag_x2}"), 

816 ) 

817 

818 if self.compute_chi: 

819 diff = DivideVector( 

820 actionA=diff, 

821 actionB=ColorError( 

822 flux_err1=DivideVector( 

823 actionA=getattr(self.process.buildActions, f"flux_err_{mag_y1}"), 

824 actionB=getattr(self.process.buildActions, f"flux_{mag_y1}"), 

825 ), 

826 flux_err2=DivideVector( 

827 actionA=getattr(self.process.buildActions, f"flux_err_{mag_y2}"), 

828 actionB=getattr(self.process.buildActions, f"flux_{mag_y2}"), 

829 ), 

830 ), 

831 ) 

832 setattr(self.process.buildActions, f"diff{plot.suffix_y}", diff) 

833 

834 label = f"({band1} - {band2}) ({config_mag_y.name_flux} - {config_mag_x.name_flux})" 

835 label = f"χ = ({label})/σ" if self.compute_chi else f"{label} (mmag)" 

836 plot.yAxisLabel = label 

837 actions_metric[name_color] = metric 

838 actions_plot[name_color] = plot 

839 idx += 1 

840 if do_metrics: 840 ↛ 845line 840 didn't jump to line 845 because the condition on line 840 was always true

841 action_metric = StructMetricAction() 

842 for name_action, action in actions_metric.items(): 

843 setattr(action_metric.actions, name_action, action) 

844 self.produce.metric = action_metric 

845 action_plot = StructPlotAction() 

846 for name_action, action in actions_plot.items(): 

847 setattr(action_plot.actions, name_action, action) 

848 self.produce.plot = action_plot 

849 

850 def get_key_flux_y(self) -> str: 

851 return self.mag_y1 

852 

853 def reconfigure_dependent_magnitudes( 

854 self, 

855 key_flux_meas: str | None = None, 

856 bands_color: dict[str, str] | list[str] | None = None, 

857 ): 

858 if key_flux_meas is not None: 

859 _set_field_config(self, name="mag_y1", value=key_flux_meas) 

860 if bands_color is not None: 

861 if isinstance(bands_color, dict): 

862 _set_field_config(self, name="bands", value=bands_color) 

863 else: 

864 bands_new = {} 

865 bands_old = self.bands.default if inspect.isclass(self) else self.bands 

866 for band in bands_color: 

867 colors = bands_old.get(band) 

868 if colors is None: 

869 raise ValueError( 

870 f"Passed {bands_color=} to reconfigure colors for {self=} but {band=}" 

871 f" is not in {bands_old=}." 

872 ) 

873 bands_new[band] = ",".join(band for band in colors.split(",") if band in bands_color) 

874 _set_field_config(self, name="bands", value=bands_new) 

875 

876 def setDefaults(self): 

877 super().setDefaults() 

878 self.produce.plot.yLims = self.limits_diff_color_mmag_default 

879 

880 def validate(self): 

881 super().validate() 

882 errors = [] 

883 for band1, band2_list in self.bands.items(): 

884 bands = self._split_bands(band2_list) 

885 if len(set(bands)) != len(bands): 

886 errors.append(f"value={band2_list} is not a set for key={band1}") 

887 if errors: 

888 raise ValueError("\n".join(errors)) 

889 

890 

891class MatchedRefCoaddDiffColorZoomTool(MatchedRefCoaddDiffColorTool): 

892 def setDefaults(self): 

893 super().setDefaults() 

894 self.produce.plot.yLims = self.limits_diff_color_mmag_zoom_default 

895 self.produce.metric = NoMetric 

896 

897 

898class MatchedRefCoaddChiColorTool(MatchedRefCoaddDiffColorTool): 

899 def setDefaults(self): 

900 super().setDefaults() 

901 self.compute_chi = True 

902 self.produce.plot.yLims = self.limits_chi_default 

903 

904 

905class MatchedRefCoaddDiffMagTool(MatchedRefCoaddDiffPlot): 

906 """Tool for diffs between reference and measured coadd mags.""" 

907 

908 mag_y = pexConfig.Field[str]( 

909 default="cmodel_err", 

910 doc="Flux (magnitude) pexConfig.Field to difference against the x-axis values", 

911 ) 

912 measure_y_minus_x = pexConfig.Field[bool]( 

913 default=True, doc="Whether to plot the y-axis magnitude minus the x-axis; otherwise x-y if False." 

914 ) 

915 

916 def finalize(self): 

917 # Check if it has already been finalized 

918 if not hasattr(self.process.buildActions, "diff"): 918 ↛ exitline 918 didn't return from function 'finalize' because the condition on line 918 was always true

919 # Ensure mag_y is set before any plot finalizes 

920 self._set_flux_default("mag_y") 

921 super().finalize() 

922 self._set_actions() 

923 name_short_x = self.config_mag_x.name_flux_short 

924 name_short_y = self.config_mag_y.name_flux_short 

925 

926 prefix_action = "flux" if self.compute_chi else "mag" 

927 actionA, actionB = ( 

928 getattr(self.process.buildActions, f"{prefix_action}_{mag}") 

929 for mag in ((self.mag_y, self.mag_x) if self.measure_y_minus_x else (self.mag_x, self.mag_y)) 

930 ) 

931 action_diff = SubtractVector(actionA=actionA, actionB=actionB) 

932 

933 if self.compute_chi: 

934 key_err = f"flux_err_{self.mag_y}" 

935 action_err = ( 

936 getattr(self.process.buildActions, key_err) 

937 if hasattr(self.process.buildActions, key_err) 

938 else getattr(self.process.buildActions, f"flux_err_{self.mag_x}") 

939 ) 

940 self.process.buildActions.diff = DivideVector(actionA=action_diff, actionB=action_err) 

941 else: 

942 # set to mmag 

943 self.process.buildActions.diff = MultiplyVector( 

944 actionA=action_diff, 

945 actionB=ConstantValue(value=1000.0), 

946 ) 

947 if not self.produce.plot.yAxisLabel: 947 ↛ 951line 947 didn't jump to line 951 because the condition on line 947 was always true

948 label_x, label_y = (mag.name_flux for mag in (self.config_mag_x, self.config_mag_y)) 

949 label = f"{label_y} - {label_x}" if self.measure_y_minus_x else f"{label_x} - {label_y}" 

950 self.produce.plot.yAxisLabel = f"χ = ({label})/σ" if self.compute_chi else f"{label} (mmag)" 

951 if self.unit is None: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true

952 self.unit = "" if self.compute_chi else "mmag" 

953 if self.name_prefix is None: 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true

954 subtype = "chi" if self.compute_chi else "diff" 

955 self.name_prefix = f"photom_{name_short_y}_vs_{name_short_x}_mag_{subtype}_{{name_type}}_" 

956 if self.do_metrics() and not self.produce.metric.units: 956 ↛ exitline 956 didn't return from function 'finalize' because the condition on line 956 was always true

957 self.produce.metric.units = self.configureMetrics() 

958 

959 def get_key_flux_y(self) -> str: 

960 return self.mag_y 

961 

962 def reconfigure_dependent_magnitudes( 

963 self, 

964 key_flux_meas: str | None = None, 

965 bands_color: dict[str, str] | None = None, 

966 ): 

967 if key_flux_meas is not None: 

968 _set_field_config(self, name="mag_y", value=key_flux_meas) 

969 

970 def setDefaults(self): 

971 super().setDefaults() 

972 self.produce.plot.yLims = self.limits_diff_mag_mmag_default 

973 

974 

975class MatchedRefCoaddDiffMagZoomTool(MatchedRefCoaddDiffMagTool): 

976 def setDefaults(self): 

977 super().setDefaults() 

978 self.produce.plot.yLims = self.limits_diff_mag_mmag_zoom_default 

979 self.produce.metric = NoMetric 

980 

981 

982class MatchedRefCoaddChiMagTool(MatchedRefCoaddDiffMagTool): 

983 def setDefaults(self): 

984 super().setDefaults() 

985 self.compute_chi = True 

986 self.produce.plot.yLims = self.limits_chi_default 

987 

988 

989class MatchedRefCoaddDiffPositionTool(MatchedRefCoaddDiffPlot): 

990 """Tool for diffs between reference and measured coadd positions.""" 

991 

992 coord_label = Field[str]( 

993 doc="The plot label for the astrometric variable (default coord_meas)", 

994 optional=True, 

995 default=None, 

996 ) 

997 coord_meas = Field[str]( 

998 doc="The key for measured values of the astrometric variable", 

999 optional=False, 

1000 ) 

1001 coord_ref = Field[str]( 

1002 doc="The key for reference values of the astrometric variable", 

1003 optional=False, 

1004 ) 

1005 coord_ref_cos = Field[str]( 

1006 doc="The key for reference values of the cosine correction astrometric variable" 

1007 " (i.e. dec if coord_meas is RA)", 

1008 default=None, 

1009 optional=True, 

1010 ) 

1011 coord_ref_cos_unit = Field[str]( 

1012 doc="astropy unit of coord_ref_cos", 

1013 default="deg", 

1014 optional=True, 

1015 ) 

1016 mag_sn = Field[str](default="cmodel_err", doc="Flux (magnitude) field to use for S/N binning on plot") 

1017 # Default coords are in degrees and we want mas 

1018 scale_factor = Field[float]( 

1019 doc="The factor to multiply distances by (e.g. the pixel scale if coordinates have pixel units or " 

1020 "the desired spherical coordinate unit conversion otherwise)", 

1021 default=3600000, 

1022 ) 

1023 

1024 def finalize(self): 

1025 # Check if it has already been finalized 

1026 if not hasattr(self.process.buildActions, "diff"): 1026 ↛ exitline 1026 didn't return from function 'finalize' because the condition on line 1026 was always true

1027 # Set before MagnitudeScatterPlot.finalize to undo PSF default. 

1028 # Matched ref tables may not have PSF fluxes, or prefer CModel. 

1029 self._set_flux_default("mag_sn") 

1030 super().finalize() 

1031 self._set_actions() 

1032 name = self.coord_label if self.coord_label else self.coord_meas 

1033 self.process.buildActions.pos_meas = LoadVector(vectorKey=self.coord_meas) 

1034 self.process.buildActions.pos_ref = LoadVector(vectorKey=self.coord_ref) 

1035 name_short_x = self.config_mag_x.name_flux_short 

1036 name_short_y = self.config_mag_y.name_flux_short 

1037 

1038 if self.compute_chi: 

1039 self.process.buildActions.diff = DivideVector( 

1040 actionA=SubtractVector( 

1041 actionA=self.process.buildActions.pos_meas, 

1042 actionB=self.process.buildActions.pos_ref, 

1043 ), 

1044 actionB=LoadVector(vectorKey=f"{self.process.buildActions.pos_meas.vectorKey}Err"), 

1045 ) 

1046 else: 

1047 factor = ConstantValue(value=self.scale_factor) 

1048 if self.coord_ref_cos: 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true

1049 factor_cos = u.Unit(self.coord_ref_cos_unit).to(u.rad) 

1050 factor = MultiplyVector( 

1051 actionA=factor, 

1052 actionB=CosVector( 

1053 actionA=MultiplyVector( 

1054 actionA=ConstantValue(value=factor_cos), 

1055 actionB=LoadVector(vectorKey=self.coord_ref_cos), 

1056 ) 

1057 ), 

1058 ) 

1059 self.process.buildActions.diff = MultiplyVector( 

1060 actionA=factor, 

1061 actionB=SubtractVector( 

1062 actionA=self.process.buildActions.pos_meas, 

1063 actionB=self.process.buildActions.pos_ref, 

1064 ), 

1065 ) 

1066 if self.unit is None: 1066 ↛ 1068line 1066 didn't jump to line 1068 because the condition on line 1066 was always true

1067 self.unit = "" if self.compute_chi else "mas" 

1068 if self.name_prefix is None: 1068 ↛ 1075line 1068 didn't jump to line 1075 because the condition on line 1068 was always true

1069 subtype = "chi" if self.compute_chi else "diff" 

1070 coord_prefix = "" if "coord" in self.coord_meas else "coord_" 

1071 self.name_prefix = ( 

1072 f"astrom_{name_short_y}_vs_{name_short_x}_{coord_prefix}{self.coord_meas}_{subtype}" 

1073 f"_{{name_type}}_" 

1074 ) 

1075 if self.do_metrics() and not self.produce.metric.units: 1075 ↛ 1077line 1075 didn't jump to line 1077 because the condition on line 1075 was always true

1076 self.produce.metric.units = self.configureMetrics() 

1077 if not self.produce.plot.yAxisLabel: 1077 ↛ exitline 1077 didn't return from function 'finalize' because the condition on line 1077 was always true

1078 label = f"({name_short_y} - {name_short_x})" 

1079 coord_suffix = "" if "coord" in name else " coord" 

1080 self.produce.plot.yAxisLabel = ( 

1081 f"χ = ({label} {name}{coord_suffix})/σ" 

1082 if self.compute_chi 

1083 else f"{label} {name}{coord_suffix} ({self.unit})" 

1084 ) 

1085 

1086 def get_key_flux_y(self) -> str: 

1087 return self.mag_sn 

1088 

1089 def reconfigure_dependent_magnitudes( 

1090 self, 

1091 key_flux_meas: str | None = None, 

1092 bands_color: dict[str, str] | None = None, 

1093 ): 

1094 if key_flux_meas is not None: 

1095 _set_field_config(self, name="mag_sn", value=key_flux_meas) 

1096 

1097 def setDefaults(self): 

1098 super().setDefaults() 

1099 self.produce.plot.yLims = self.limits_diff_pos_mas_default 

1100 

1101 

1102class MatchedRefCoaddDiffPositionZoomTool(MatchedRefCoaddDiffPositionTool): 

1103 def setDefaults(self): 

1104 super().setDefaults() 

1105 self.produce.plot.yLims = self.limits_diff_pos_mas_zoom_default 

1106 self.produce.metric = NoMetric 

1107 

1108 

1109class MatchedRefCoaddDiffCoordRaTool(MatchedRefCoaddDiffPositionTool): 

1110 def setDefaults(self): 

1111 super().setDefaults() 

1112 self.coord_meas = "coord_ra" 

1113 self.coord_ref = "ref_ra" 

1114 self.coord_ref_cos = "ref_dec" 

1115 

1116 

1117class MatchedRefCoaddDiffCoordRaZoomTool(MatchedRefCoaddDiffCoordRaTool): 

1118 def setDefaults(self): 

1119 super().setDefaults() 

1120 self.produce.plot.yLims = self.limits_diff_pos_mas_zoom_default 

1121 self.produce.metric = NoMetric 

1122 

1123 

1124class MatchedRefCoaddChiCoordRaTool(MatchedRefCoaddDiffCoordRaTool): 

1125 def setDefaults(self): 

1126 super().setDefaults() 

1127 self.compute_chi = True 

1128 self.produce.plot.yLims = self.limits_chi_default 

1129 

1130 

1131class MatchedRefCoaddDiffCoordDecTool(MatchedRefCoaddDiffPositionTool): 

1132 def setDefaults(self): 

1133 super().setDefaults() 

1134 self.coord_meas = "coord_dec" 

1135 self.coord_ref = "ref_dec" 

1136 

1137 

1138class MatchedRefCoaddDiffCoordDecZoomTool(MatchedRefCoaddDiffCoordDecTool): 

1139 def setDefaults(self): 

1140 super().setDefaults() 

1141 self.produce.plot.yLims = self.limits_diff_pos_mas_zoom_default 

1142 self.produce.metric = NoMetric 

1143 

1144 

1145class MatchedRefCoaddChiCoordDecTool(MatchedRefCoaddDiffCoordDecTool): 

1146 def setDefaults(self): 

1147 super().setDefaults() 

1148 self.compute_chi = True 

1149 self.produce.plot.yLims = self.limits_chi_default 

1150 

1151 

1152class MatchedRefCoaddDiffDistanceTool(MatchedRefCoaddDiffPlot): 

1153 """Tool for distances between matched reference and measured coadd 

1154 objects.""" 

1155 

1156 key_dist = Field[str](default="match_distance", doc="Distance field key") 

1157 key_dist_err = Field[str](default="match_distanceErr", doc="Distance error field key") 

1158 mag_sn = Field[str](default="cmodel_err", doc="Flux (magnitude) field to use for S/N binning on plot") 

1159 # Default coords are in degrees and we want mas 

1160 scale_factor = Field[float]( 

1161 doc="The factor to multiply distances by (e.g. the pixel scale if coordinates have pixel units or " 

1162 "the desired spherical coordinate unit conversion otherwise)", 

1163 default=3600000, 

1164 ) 

1165 

1166 def finalize(self): 

1167 # Check if it has already been finalized 

1168 if not hasattr(self.process.buildActions, "diff"): 1168 ↛ exitline 1168 didn't return from function 'finalize' because the condition on line 1168 was always true

1169 # Set before MagnitudeScatterPlot.finalize to undo PSF default. 

1170 # Matched ref tables may not have PSF fluxes, or prefer CModel. 

1171 self._set_flux_default("mag_sn") 

1172 super().finalize() 

1173 self._set_actions() 

1174 

1175 name_short_x = self.config_mag_x.name_flux_short 

1176 name_short_y = self.config_mag_y.name_flux_short 

1177 

1178 self.process.buildActions.dist = LoadVector(vectorKey=self.key_dist) 

1179 if self.compute_chi: 

1180 self.process.buildActions.diff = DivideVector( 

1181 actionA=self.process.buildActions.dist, 

1182 actionB=LoadVector(vectorKey=self.key_dist_err), 

1183 ) 

1184 else: 

1185 self.process.buildActions.diff = MultiplyVector( 

1186 actionA=ConstantValue(value=self.scale_factor), 

1187 actionB=self.process.buildActions.dist, 

1188 ) 

1189 if self.unit is None: 1189 ↛ 1191line 1189 didn't jump to line 1191 because the condition on line 1189 was always true

1190 self.unit = "" if self.compute_chi else "mas" 

1191 if self.name_prefix is None: 1191 ↛ 1195line 1191 didn't jump to line 1195 because the condition on line 1191 was always true

1192 subtype = "chi" if self.compute_chi else "diff" 

1193 self.name_prefix = f"astrom_dist_{{name_type}}_{subtype}_" 

1194 self.name_prefix = f"astrom_{name_short_y}_vs_{name_short_x}_dist_{subtype}_{{name_type}}_" 

1195 if self.do_metrics() and not self.produce.metric.units: 1195 ↛ 1197line 1195 didn't jump to line 1197 because the condition on line 1195 was always true

1196 self.produce.metric.units = self.configureMetrics() 

1197 if not self.produce.plot.yAxisLabel: 1197 ↛ exitline 1197 didn't return from function 'finalize' because the condition on line 1197 was always true

1198 label = f"({name_short_y} - {name_short_x}) distance" 

1199 self.produce.plot.yAxisLabel = ( 

1200 f"χ = ({label})/σ" if self.compute_chi else f"{label} ({self.unit})" 

1201 ) 

1202 

1203 def get_key_flux_y(self) -> str: 

1204 return self.mag_sn 

1205 

1206 def reconfigure_dependent_magnitudes( 

1207 self, 

1208 key_flux_meas: str | None = None, 

1209 bands_color: dict[str, str] | None = None, 

1210 ): 

1211 if key_flux_meas is not None: 

1212 _set_field_config(self, name="mag_sn", value=key_flux_meas) 

1213 

1214 def setDefaults(self): 

1215 super().setDefaults() 

1216 self.produce.plot.yLims = [0, self.limits_diff_pos_mas_default[1]] 

1217 

1218 

1219class MatchedRefCoaddChiDistanceTool(MatchedRefCoaddDiffDistanceTool): 

1220 def setDefaults(self): 

1221 super().setDefaults() 

1222 self.compute_chi = True 

1223 self.produce.plot.yLims = self.limits_chi_default 

1224 

1225 

1226class MatchedRefCoaddDiffDistanceZoomTool(MatchedRefCoaddDiffDistanceTool): 

1227 def setDefaults(self): 

1228 super().setDefaults() 

1229 self.produce.plot.yLims = [0, self.limits_diff_pos_mas_zoom_default[1]] 

1230 self.produce.metric = NoMetric 

1231 

1232 

1233def reconfigure_diff_matched_defaults( 

1234 config: AnalysisBaseConfig | None = None, 

1235 context: str | None = None, 

1236 key_flux_meas: str | None = None, 

1237 bands_color: dict[str, str] | list[str] | None = None, 

1238 use_any: bool | None = None, 

1239 use_galaxies: bool | None = None, 

1240 use_stars: bool | None = None, 

1241): 

1242 """Reconfigure the default values for config fields of MatchedRefCoaddTool 

1243 and all of its subclasses. 

1244 

1245 Parameters 

1246 ---------- 

1247 config 

1248 An existing analysis config. Overrides will be applied to any of its 

1249 member MatchedRefCoaddTool atools. 

1250 context 

1251 The context to set. Must be a valid choice for 

1252 MatchedRefCoaddTool.context. 

1253 key_flux_meas 

1254 The key of the measured flux config to use, e.g. "psf". If the key is 

1255 not found, it will search for f"{key}_err", the default name for 

1256 configurations that load error keys as well as fluxes. 

1257 bands_color 

1258 A dictionary keyed by band of comma-separated bands to measure 

1259 colors for, where the color is (key - value). If a list is passed, 

1260 tools will modify the defaults to select only those bands within 

1261 the list (which should also be a set). 

1262 use_any 

1263 Whether to compute metrics for objects of all types. 

1264 use_galaxies 

1265 Whether to compute metrics and plot lines for galaxies only. 

1266 use_stars 

1267 Whether to compute metrics and plot lines for stars only. 

1268 

1269 Notes 

1270 ----- 

1271 Any kwargs set to None will not change the relevant config field defaults. 

1272 """ 

1273 if key_flux_meas is not None: 

1274 keys_flux = tuple(MagnitudeTool.fluxes_default.toDict().keys()) 

1275 if key_flux_meas not in keys_flux: 

1276 key_flux_err = f"{key_flux_meas}_err" 

1277 if key_flux_err not in keys_flux: 

1278 raise ValueError( 

1279 f"{key_flux_meas=} and {key_flux_err} not found in available keys: {keys_flux}" 

1280 f" (from MagnitudeTool.fluxes_default.toDict().keys())" 

1281 ) 

1282 key_flux_meas = key_flux_err 

1283 

1284 # These are class attributes and don't need to be changed in subclasses 

1285 # These may end up being changed multiple times with repeated calls, 

1286 # but there isn't a good way to avoid that. 

1287 MagnitudeTool.fluxes_default.ref_matched.name_flux = "True" 

1288 MagnitudeTool.fluxes_default.ref_matched.name_flux_short = "true" 

1289 MagnitudeTool.fluxes_default.ref_matched.key_flux = "ref_{band}_flux" 

1290 

1291 def all_subclasses(cls): 

1292 return set(cls.__subclasses__()).union([s for c in cls.__subclasses__() for s in all_subclasses(c)]) 

1293 

1294 subclasses = all_subclasses(MatchedRefCoaddTool) 

1295 

1296 """ 

1297 Further context on these kwargs (get it?) and why they're not contexts: 

1298 

1299 context (DC2, source_injection, etc) 

1300 This could be made an AnalysisContext, but ChoiceField has the 

1301 benefits of automatic validation. Also, subclasses refer to this config 

1302 field without having to implement separate context functions. 

1303 key_flux_meas 

1304 This is the key to a FluxConfig. Default FluxConfigs could be mapped 

1305 onto an AnalysisContext instead. 

1306 bands_color 

1307 This applies only to color tools and is intended to be set by 

1308 obs package config overrides, e.g. to drop u-band colours. There is no 

1309 way for obs packages to change Tool instance values and the bands 

1310 config field is part of the PipelineTask and not accessible to tools, 

1311 so no obvious alternative exists. 

1312 use_* 

1313 Like context, these apply to all subclasses, but are independent 

1314 booleans rather than exclusive choices. 

1315 """ 

1316 

1317 # This sets defaults for all known subclasses 

1318 for tool in subclasses: 

1319 tool.reconfigure( 

1320 tool, 

1321 context=context, 

1322 key_flux_meas=key_flux_meas, 

1323 bands_color=bands_color, 

1324 use_any=use_any, 

1325 use_galaxies=use_galaxies, 

1326 use_stars=use_stars, 

1327 ) 

1328 

1329 # This sets defaults for all existing tools 

1330 # If a pipeline A imports a pipeline B, any atools already set in B will 

1331 # be instantiated before overrides from A are applied. Therefore, changing 

1332 # only the defaults will have no effect on those existing tools. 

1333 if config is not None: 

1334 for tool in config.atools: 

1335 if isinstance(tool, MatchedRefCoaddTool): 

1336 tool: MatchedRefCoaddTool = tool 

1337 tool.reconfigure( 

1338 context=context, 

1339 key_flux_meas=key_flux_meas, 

1340 bands_color=bands_color, 

1341 use_any=use_any, 

1342 use_galaxies=use_galaxies, 

1343 use_stars=use_stars, 

1344 )