Coverage for python / lsst / analysis / tools / atools / genericBuild.py: 30%
253 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:23 +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
23__all__ = (
24 "ExtendednessTool",
25 "FluxConfig",
26 "MagnitudeTool",
27 "MagnitudeXTool",
28 "ObjectClassTool",
29 "SizeConfig",
30 "SizeTool",
31)
33import copy
35from lsst.pex.config import ChoiceField, Config, ConfigDictField, ConfigField, Field
36from lsst.pex.config.configurableActions import ConfigurableActionField, ConfigurableActionStructField
38from ..actions.vector import (
39 CalcMomentSize,
40 ConstantValue,
41 ConvertFluxToMag,
42 DownselectVector,
43 LoadVector,
44 Log10Vector,
45 MultiplyVector,
46 VectorSelector,
47)
48from ..actions.vector.selectors import (
49 CoaddPlotFlagSelector,
50 GalaxySelector,
51 SelectorBase,
52 StarSelector,
53 ThresholdSelector,
54 VisitPlotFlagSelector,
55)
56from ..interfaces import AnalysisTool, KeyedData, Vector, VectorAction
59class ExtendednessTool(AnalysisTool):
60 """Select (non-)extended sources in visit/coadd contexts."""
62 extendedness = Field[str](
63 default="refExtendedness",
64 doc="Extendedness field to select sub-samples with",
65 )
67 parameterizedBand = Field[bool](
68 default=True,
69 doc="Does this AnalysisTool support band as a name parameter",
70 )
72 def coaddContext(self) -> None:
73 self.prep.selectors.flagSelector = CoaddPlotFlagSelector()
74 self.prep.selectors.flagSelector.bands = ["{band}"]
76 def visitContext(self) -> None:
77 self.parameterizedBand = False
78 self.prep.selectors.flagSelector = VisitPlotFlagSelector()
80 def setDefaults(self):
81 super().setDefaults()
82 # Select any finite extendedness (but still exclude NaNs)
83 self.process.buildActions.allSelector = StarSelector(
84 vectorKey=self.extendedness, extendedness_maximum=1.0
85 )
86 self.process.buildActions.galaxySelector = GalaxySelector(vectorKey=self.extendedness)
87 self.process.buildActions.starSelector = StarSelector(vectorKey=self.extendedness)
90class FluxConfig(Config):
91 """Configuration for a flux vector to be loaded and potentially plotted."""
93 key_flux = Field[str](default=None, doc="Format of the flux field to convert to magnitudes with {band}.")
94 key_flux_error = Field[str](default=None, doc="Format of the flux error field.", optional=True)
95 name_flux_short = Field[str](
96 default=None,
97 doc="Short name of the flux/magnitude algorithm/model to use in metric keys",
98 )
99 name_flux = Field[str](
100 default=None,
101 doc="Name of the flux/magnitude algorithm/model to use in plot labels.",
102 )
104 def key_flux_band(self, band: str):
105 return self.key_flux.format(band=band)
107 def key_flux_error_band(self, band: str):
108 return self.key_flux_error.format(band=band)
111class FluxesDefaultConfig(Config):
112 bulge_err = ConfigField[FluxConfig](doc="Bulge model magnitude with errors")
113 cmodel_err = ConfigField[FluxConfig](doc="CModel total magnitude with errors")
114 disk_err = ConfigField[FluxConfig](doc="Disk model magnitude with errors")
115 gaap1p0_err = ConfigField[FluxConfig](doc="Gaap 1.0 arcsec aperture magnitude with errors")
116 kron_err = ConfigField[FluxConfig](doc="Kron aperture magnitude with errors")
117 psf_err = ConfigField[FluxConfig](doc="PSF model magnitude with errors")
118 ref_matched = ConfigField[FluxConfig](doc="Reference catalog magnitude")
119 sersic_err = ConfigField[FluxConfig](doc="Sersic total magnitude with errors")
122class ObjectSelector(ThresholdSelector):
123 """A selector that selects primary objects from an object table."""
125 def __call__(self, data: KeyedData, **kwargs) -> Vector:
126 result = super().__call__(data=data, **kwargs)
127 if self.plotLabelKey:
128 self._addValueToPlotInfo("primary objects", **kwargs)
129 return result
131 def setDefaults(self):
132 super().setDefaults()
133 self.op = "eq"
134 self.threshold = 1
135 self.plotLabelKey = ""
136 self.vectorKey = "detect_isPrimary"
139class ObjectClassTool(AnalysisTool):
140 """Config for tools that compute metrics for multiple classes of object.
142 Class refers to e.g. the star-galaxy classification (including other
143 types such as AGN), whereas type refers to the more generic (un)resolved
144 status of the object. Variability may be included in the future.
145 """
147 _plurals = {
148 # This is a bit of a hack to keep metric names consistent
149 # They never changed from all to any, whereas scatterPlot did
150 "any": "all",
151 "galaxy": "galaxies",
152 "star": "stars",
153 }
155 selection_suffix = Field[str](
156 doc="Suffix to append to selector names to summarize selection criteria",
157 default="",
158 )
159 selector_all = ConfigurableActionField[SelectorBase](
160 doc="The selector for target objects of all types",
161 default=ObjectSelector,
162 )
163 selector_galaxy = ConfigurableActionField[SelectorBase](
164 doc="The selector for target galaxies",
165 default=GalaxySelector,
166 )
167 selector_star = ConfigurableActionField[SelectorBase](
168 doc="The selector for target stars",
169 default=StarSelector,
170 )
172 type_any = Field[str](doc="The classification for any type", default="all")
173 type_galaxies = Field[str](doc="The classification for galaxies", default="resolved")
174 type_stars = Field[str](doc="The classification for galaxies", default="unresolved")
176 use_any = Field[bool](doc="Whether any (all types) be a used category of type", default=True)
177 use_galaxies = Field[bool](doc="Whether galaxies be a used category of type", default=True)
178 use_stars = Field[bool](doc="Whether stars should be a used category of types", default=True)
180 def get_class_name_plural(self, object_class: str):
181 """Return the plural form of a class name."""
182 return self._plurals[object_class]
184 def get_class_type(self, object_class: str):
185 """Return the type of a given class of objects."""
186 match object_class:
187 case "any":
188 return self.type_any
189 case "galaxy":
190 return self.type_galaxies
191 case "star":
192 return self.type_stars
194 def get_classes(self):
195 """Return all of the classes to be used."""
196 classes = []
197 if self.use_any:
198 classes.append("any")
199 if self.use_galaxies:
200 classes.append("galaxy")
201 if self.use_stars:
202 classes.append("star")
203 return classes
205 def get_name_attr_selector(self, object_class: str, selector_suffix: str = None):
206 if selector_suffix is None:
207 selector_suffix = self.selection_suffix
208 return f"selector{selector_suffix}_{self.get_class_name_plural(object_class)}"
210 def get_name_attr_values(self, object_class: str, prefix: str = "y"):
211 return f"{prefix}{self.get_class_name_plural(object_class).capitalize()}"
213 def get_selector(self, object_class: str):
214 """Get the selector for a given object class."""
215 match object_class:
216 case "any":
217 return self.selector_all
218 case "galaxy":
219 return self.selector_galaxy
220 case "star":
221 return self.selector_star
223 def finalize(self):
224 for object_class in self.get_classes():
225 name_selector = self.get_name_attr_selector(object_class)
226 selector = self.get_selector(object_class)
227 # This is a build action because selectors in prep are applied
228 # with the and operator. We're not using these to filter all rows
229 # but to make several parallel selections.
230 setattr(self.process.buildActions, name_selector, selector)
232 def setDefaults(self):
233 super().setDefaults()
234 for selector in (self.selector_galaxy, self.selector_star):
235 selector.vectorKey = "refExtendedness"
238class MagnitudeTool(ObjectClassTool):
239 """Compute magnitudes from flux columns.
241 This tool is designed to make it easy to configure multiple fluxes that
242 are then converted into magnitudes. For example, a plot might show one
243 magnitude on the x-axis, a second on the y-axis, and plot statistics as a
244 function of signal-to-noise from a third magnitude.
246 The fluxes_default attribute contains commonly used configurations for
247 object tables. The "_err" suffix that a flux has an associated error
248 column that is needed for some calculation; it can and should be omitted
249 if the error column is unneeded. Some flux columns in reference catalogs
250 may not have an error at all, such as injection catalogs or truth catalogs
251 from simulations.
253 Notes
254 -----
255 Any tool that reads in flux columns and converts them to magnitudes can
256 derive from this class and use the _add_flux method to set the
257 necessary build actions in their own finalize() methods.
258 """
260 fluxes_default = FluxesDefaultConfig(
261 bulge_err=FluxConfig(
262 key_flux="{band}_cModel_devFlux",
263 key_flux_error="{band}_cModel_devFluxErr",
264 name_flux="CModel Bulge",
265 name_flux_short="bulge_cModel",
266 ),
267 cmodel_err=FluxConfig(
268 key_flux="{band}_cModelFlux",
269 key_flux_error="{band}_cModelFluxErr",
270 name_flux="CModel",
271 name_flux_short="cModel",
272 ),
273 disk_err=FluxConfig(
274 key_flux="{band}_cModel_expFlux",
275 key_flux_error="{band}_cModel_expFluxErr",
276 name_flux="CModel Disk",
277 name_flux_short="disk_cModel",
278 ),
279 gaap1p0_err=FluxConfig(
280 key_flux="{band}_gaap1p0Flux",
281 key_flux_error="{band}_gaap1p0FluxErr",
282 name_flux='GAaP 1.0"',
283 name_flux_short="gaap_1p0",
284 ),
285 kron_err=FluxConfig(
286 key_flux="{band}_kronFlux",
287 key_flux_error="{band}_kronFluxErr",
288 name_flux="Kron",
289 name_flux_short="kron",
290 ),
291 psf_err=FluxConfig(
292 key_flux="{band}_psfFlux",
293 key_flux_error="{band}_psfFluxErr",
294 name_flux="PSF",
295 name_flux_short="psf",
296 ),
297 ref_matched=FluxConfig(
298 key_flux="refcat_flux_{band}",
299 key_flux_error=None,
300 name_flux="Reference",
301 name_flux_short="ref",
302 ),
303 sersic_err=FluxConfig(
304 key_flux="{band}_sersicFlux",
305 key_flux_error="{band}_sersicFluxErr",
306 name_flux="Sérsic",
307 name_flux_short="sersic",
308 ),
309 )
311 fluxes = ConfigDictField[str, FluxConfig]( # type: ignore
312 default={},
313 doc="Flux fields to convert to magnitudes",
314 )
316 def _add_flux(self, name: str, config: FluxConfig, band: str | None = None) -> str:
317 """Add requisite buildActions for a given flux.
319 Parameters
320 ----------
321 name
322 The name of the flux, without "flux_" prefix.
323 config
324 The configuration for the flux.
325 band
326 The name of the band. Default "{band}" assumes the this band is
327 the parameterized band.
329 Returns
330 -------
331 name
332 The name of the flux, suffixed by band if band is not None.
333 """
334 if band is None:
335 band = "{band}"
336 else:
337 name = f"{name}_{band}"
338 key_flux = config.key_flux_band(band=band)
339 name_flux = f"flux_{name}"
340 self._set_action(self.process.buildActions, name_flux, LoadVector, vectorKey=key_flux)
341 if config.key_flux_error is not None:
342 # Pre-emptively loaded for e.g. future S/N calculations
343 key_flux_err = config.key_flux_error_band(band=band)
344 self._set_action(
345 self.process.buildActions, f"flux_err_{name}", LoadVector, vectorKey=key_flux_err
346 )
347 self._set_action(self.process.buildActions, f"mag_{name}", ConvertFluxToMag, vectorKey=key_flux)
348 return name
350 def _config_mag(self, name_mag: str = "mag_x"):
351 attr = getattr(self, name_mag)
352 if attr not in self.fluxes:
353 raise KeyError(f"self.{name_mag}={attr} not in {self.fluxes}; was finalize called?")
354 return self.fluxes[attr]
356 def _finalize_mag(self, name_mag: str = "mag_x", prefix: str = "x"):
357 self._set_flux_default(name_mag)
358 attr = getattr(self, name_mag)
359 key_mag = f"mag_{attr}"
360 classes = self.get_classes()
361 for object_class in classes:
362 name_selector = self.get_name_attr_selector(object_class)
363 self._set_action(
364 self.process.filterActions,
365 self.get_name_attr_values(object_class, prefix=prefix),
366 DownselectVector,
367 vectorKey=key_mag,
368 selector=VectorSelector(vectorKey=name_selector),
369 )
371 def _set_action(self, target: ConfigurableActionStructField, name: str, action, *args, **kwargs):
372 """Set an action attribute on a target tool's struct field.
374 Parameters
375 ----------
376 target
377 The ConfigurableActionStructField to set an attribute on.
378 name
379 The name of the attribute to set.
380 action
381 The action class to set the attribute to.
382 args
383 Arguments to pass when initialization the action.
384 kwargs
385 Keyword arguments to pass when initialization the action.
386 """
387 if hasattr(target, name):
388 attr = getattr(target, name)
389 # Setting an attr to a different action is a logic error
390 assert isinstance(attr, action)
391 # Assert that the action's attributes are identical
392 for key, value in kwargs.items():
393 if value.__class__.__module__ == "__builtin__":
394 assert getattr(attr, key) == value
395 else:
396 setattr(target, name, action(*args, **kwargs))
398 def _set_flux_default(self, attr: str, band: str | None = None, name_mag: str | None = None) -> str:
399 """Set own config attr to appropriate string flux name.
401 Parameters
402 ----------
403 attr
404 The name of the attribute to set.
405 band
406 The name of the band to pass to _add_flux.
407 name_mag
408 The name of the magnitude to configure. If None, self must already
409 have an attr, and name_mag is set to the attr's value.
410 """
411 name_mag_is_none = name_mag is None
412 if name_mag_is_none:
413 name_mag = getattr(self, attr)
414 complete = name_mag in self.fluxes
415 else:
416 complete = hasattr(self, attr)
417 # Do nothing if already set - may have been called 2+ times
418 if not complete:
419 name_found = None
420 drop_err = False
421 # Check if the name with errors is a configured default
422 if name_mag.endswith("_err"):
423 if hasattr(self.fluxes_default, name_mag):
424 name_found = name_mag
425 else:
426 if hasattr(self.fluxes_default, name_mag):
427 name_found = name_mag
428 # Check if a config with errors exists but not without
429 elif hasattr(self.fluxes_default, f"{name_mag}_err"):
430 name_found = f"{name_mag}_err"
431 # Don't load the errors - no _err suffix == unneeded
432 drop_err = True
433 if name_found:
434 # Copy the config - we don't want to edit in place
435 # Other instances may use them
436 value = copy.copy(getattr(self.fluxes_default, name_found))
437 # Ensure no unneeded error columns are loaded
438 if drop_err:
439 value.key_flux_error = None
440 self.fluxes[name_found] = value
441 name_found = self._add_flux(name=name_found, config=value, band=band)
442 else:
443 raise RuntimeError(
444 f"flux={name_mag} not defined in self.fluxes={self.fluxes}"
445 f" and no default configuration found"
446 )
447 if name_mag_is_none and (name_mag != name_found):
448 # Essentially appends _err to the name if needed
449 setattr(self, attr, name_found)
451 def finalize(self):
452 super().finalize()
453 for key, config in self.fluxes.items():
454 self._add_flux(name=key, config=config)
457class MagnitudeXTool(MagnitudeTool):
458 """A Tool for metrics/plots with a magnitude as the dependent variable."""
460 mag_x = Field[str](
461 doc="Flux (magnitude) FluxConfig key (in self.fluxes) to bin metrics by or plot on x-axis",
462 )
464 @property
465 def config_mag_x(self):
466 return self._config_mag()
468 def finalize(self):
469 super().finalize()
470 self._finalize_mag()
473class SizeConfig(Config):
474 """Configuration for size vector(s) to be loaded and possibly plotted."""
476 has_moments = Field[bool](doc="Whether this size measure is stored as 2D moments.", default=True)
477 key_size = Field[str](
478 doc="Size column(s) to compute/plot, including moment suffix as {suffix}.",
479 )
480 log10_size = Field[bool](
481 default=True,
482 doc="Whether to compute/plot)log10 of the sizes.",
483 )
484 name_size = Field[str](
485 default="size",
486 doc="Name of the size (e.g. for axis labels).",
487 )
488 scale_size = Field[float](
489 default=0.2,
490 doc="Factor to scale sizes (multiply) by.",
491 )
492 unit_size = Field[str](
493 default="arcsec",
494 doc="Unit for sizes.",
495 )
497 def modify_action(self, action: VectorAction) -> VectorAction:
498 if self.log10_size:
499 action = Log10Vector(actionA=action)
500 return action
503class SizeDefaultConfig(Config):
504 bulge = ConfigField[SizeConfig](doc="Bulge model size config.")
505 disk = ConfigField[SizeConfig](doc="Disk model size config.")
506 moments = ConfigField[SizeConfig](doc="Second moments size config.")
507 sersic = ConfigField[SizeConfig](doc="Sersic effective radius config")
508 shape_slot = ConfigField[SizeConfig](doc="Shape slot size config.")
511class MomentsConfig(Config):
512 """Configuration for moment field suffixes."""
514 xx = Field[str](doc="Suffix for the x/xx moments.", default="xx")
515 xy = Field[str](doc="Suffix for the rho value/xy moments.", default="xy")
516 yy = Field[str](doc="Suffix for the y/yy moments.", default="yy")
519class SizeTool(ObjectClassTool):
520 """Compute various object size definitions in linear or log space.
522 This tool is designed to make it easy to configure a size based on the
523 definition of the size and the configuration of the columns that it is
524 read from.
526 It currently only supports a single size but may be refactored to
527 support arbitrary sizes, like MagnitudeTool.
528 """
530 attr_prefix = Field[str](doc="Prefix to prepend to size names as attrs", default="size_", optional=False)
531 config_moments = ConfigField[MomentsConfig](
532 doc="Configuration for moment field names", default=MomentsConfig
533 )
534 is_covariance = Field[bool](
535 doc="Whether this size has multiple fields as for a covariance matrix."
536 " If False, the XX/YY/XY terms are instead assumed to map to sigma_x/sigma_y/rho.",
537 default=True,
538 )
539 sizes_default = SizeDefaultConfig(
540 bulge=SizeConfig(
541 key_size="{band}_cModel_dev_reff_major", name_size="CModel Bulge $R_{eff}$", has_moments=False
542 ),
543 disk=SizeConfig(
544 key_size="{band}_cModel_exp_reff_major", name_size="CModel Disk $R_{eff}$", has_moments=False
545 ),
546 moments=SizeConfig(key_size="{band}_i{suffix}", name_size="Second moment radius"),
547 sersic=SizeConfig(
548 key_size="sersic_reff_major", name_size="Sérsic $R_{eff,major}$", has_moments=False
549 ),
550 shape_slot=SizeConfig(key_size="shape_{suffix}", name_size="Shape slot radius"),
551 )
552 size_type = ChoiceField[str](
553 doc="The type of size to calculate",
554 allowed={
555 "determinantRadius": "The (matrix) determinant radius from x/y moments.",
556 "traceRadius": "The (matrix) trace radius from x/y moments.",
557 "singleColumnSize": "A pre-computed size from a single column.",
558 },
559 optional=False,
560 )
561 size_y = Field[str](default=None, doc="Name of size field to plot on y axis.")
562 sizes = ConfigDictField[str, SizeConfig]( # type: ignore
563 default={},
564 doc="Size fields to add to build actions",
565 )
567 def _check_attr(self, name_size: str):
568 """Check if a buildAction has already been set."""
569 attr = self.get_attr_name(name_size)
570 if hasattr(self.process.buildActions, attr):
571 raise RuntimeError(f"Can't re-set size build action with already-used {attr=} from {name_size=}")
573 def _get_action_determinant(self, config):
574 action = CalcMomentSize(
575 colXx=config.key_size.format(suffix=self.config_moments.xx),
576 colYy=config.key_size.format(suffix=self.config_moments.yy),
577 colXy=config.key_size.format(suffix=self.config_moments.xy),
578 is_covariance=self.is_covariance,
579 )
580 return action
582 def _get_action_trace(self, config):
583 action = CalcMomentSize(
584 colXx=config.key_size.format(suffix=self.config_moments.xx),
585 colYy=config.key_size.format(suffix=self.config_moments.yy),
586 is_covariance=self.is_covariance,
587 )
588 return action
590 def _get_action_single_column(self, config):
591 action = LoadVector(vectorKey=config.key_size)
592 return action
594 def get_attr_name(self, name_size):
595 """Return the build action attribute for a size of a given name."""
596 return f"{self.attr_prefix}{name_size}"
598 def setDefaults(self):
599 super().setDefaults()
600 self.produce.plot.legendLocation = "lower left"
602 def finalize(self):
603 # A lazy check for whether finalize has already been called
604 classes = self.get_classes()
605 if hasattr(self.process.filterActions, self.get_name_attr_values(classes[0])):
606 return
607 super().finalize()
608 if not self.size_y:
609 raise ValueError("Must specify size_y")
610 elif self.size_y not in self.sizes:
611 if size_y := getattr(self.sizes_default, self.size_y, None):
612 self.sizes[self.size_y] = size_y
613 else:
614 raise RuntimeError(f"{self.size_y=} not found in {self.sizes=} or {self.sizes_default=}")
616 if self.size_type == "determinantRadius":
617 get_action = self._get_action_determinant
618 elif self.size_type == "traceRadius":
619 get_action = self._get_action_trace
620 elif self.size_type == "singleColumnSize":
621 get_action = self._get_action_single_column
622 else:
623 raise ValueError(f"Unsupported {self.size_type=}")
625 for name, config in self.sizes.items():
626 self._check_attr(name)
627 action = config.modify_action(
628 MultiplyVector(
629 actionA=get_action(config=config),
630 actionB=ConstantValue(value=config.scale_size),
631 )
632 )
633 setattr(self.process.buildActions, self.get_attr_name(name), action)
635 attr = self.get_attr_name(self.size_y)
636 classes = self.get_classes()
637 for object_class in classes:
638 setattr(
639 self.process.filterActions,
640 self.get_name_attr_values(object_class),
641 DownselectVector(
642 vectorKey=attr,
643 selector=VectorSelector(vectorKey=self.get_name_attr_selector(object_class)),
644 ),
645 )