Coverage for python / lsst / analysis / tools / atools / genericBuild.py: 30%
257 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 08:45 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 08:45 +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")
120 exponential_err = ConfigField[FluxConfig](doc="Exponential total magnitude with errors")
123class ObjectSelector(ThresholdSelector):
124 """A selector that selects primary objects from an object table."""
126 def __call__(self, data: KeyedData, **kwargs) -> Vector:
127 result = super().__call__(data=data, **kwargs)
128 if self.plotLabelKey:
129 self._addValueToPlotInfo("primary objects", **kwargs)
130 return result
132 def setDefaults(self):
133 super().setDefaults()
134 self.op = "eq"
135 self.threshold = 1
136 self.plotLabelKey = ""
137 self.vectorKey = "detect_isPrimary"
140class ObjectClassTool(AnalysisTool):
141 """Config for tools that compute metrics for multiple classes of object.
143 Class refers to e.g. the star-galaxy classification (including other
144 types such as AGN), whereas type refers to the more generic (un)resolved
145 status of the object. Variability may be included in the future.
146 """
148 _plurals = {
149 # This is a bit of a hack to keep metric names consistent
150 # They never changed from all to any, whereas scatterPlot did
151 "any": "all",
152 "galaxy": "galaxies",
153 "star": "stars",
154 }
156 selection_suffix = Field[str](
157 doc="Suffix to append to selector names to summarize selection criteria",
158 default="",
159 )
160 selector_all = ConfigurableActionField[SelectorBase](
161 doc="The selector for target objects of all types",
162 default=ObjectSelector,
163 )
164 selector_galaxy = ConfigurableActionField[SelectorBase](
165 doc="The selector for target galaxies",
166 default=GalaxySelector,
167 )
168 selector_star = ConfigurableActionField[SelectorBase](
169 doc="The selector for target stars",
170 default=StarSelector,
171 )
173 type_any = Field[str](doc="The classification for any type", default="all")
174 type_galaxies = Field[str](doc="The classification for galaxies", default="resolved")
175 type_stars = Field[str](doc="The classification for galaxies", default="unresolved")
177 use_any = Field[bool](doc="Whether any (all types) be a used category of type", default=True)
178 use_galaxies = Field[bool](doc="Whether galaxies be a used category of type", default=True)
179 use_stars = Field[bool](doc="Whether stars should be a used category of types", default=True)
181 def get_class_name_plural(self, object_class: str):
182 """Return the plural form of a class name."""
183 return self._plurals[object_class]
185 def get_class_type(self, object_class: str):
186 """Return the type of a given class of objects."""
187 match object_class:
188 case "any":
189 return self.type_any
190 case "galaxy":
191 return self.type_galaxies
192 case "star":
193 return self.type_stars
195 def get_classes(self):
196 """Return all of the classes to be used."""
197 classes = []
198 if self.use_any:
199 classes.append("any")
200 if self.use_galaxies:
201 classes.append("galaxy")
202 if self.use_stars:
203 classes.append("star")
204 return classes
206 def get_name_attr_selector(self, object_class: str, selector_suffix: str = None):
207 if selector_suffix is None:
208 selector_suffix = self.selection_suffix
209 return f"selector{selector_suffix}_{self.get_class_name_plural(object_class)}"
211 def get_name_attr_values(self, object_class: str, prefix: str = "y"):
212 return f"{prefix}{self.get_class_name_plural(object_class).capitalize()}"
214 def get_selector(self, object_class: str):
215 """Get the selector for a given object class."""
216 match object_class:
217 case "any":
218 return self.selector_all
219 case "galaxy":
220 return self.selector_galaxy
221 case "star":
222 return self.selector_star
224 def finalize(self):
225 for object_class in self.get_classes():
226 name_selector = self.get_name_attr_selector(object_class)
227 selector = self.get_selector(object_class)
228 # This is a build action because selectors in prep are applied
229 # with the and operator. We're not using these to filter all rows
230 # but to make several parallel selections.
231 setattr(self.process.buildActions, name_selector, selector)
233 def setDefaults(self):
234 super().setDefaults()
235 for selector in (self.selector_galaxy, self.selector_star):
236 selector.vectorKey = "refExtendedness"
239class MagnitudeTool(ObjectClassTool):
240 """Compute magnitudes from flux columns.
242 This tool is designed to make it easy to configure multiple fluxes that
243 are then converted into magnitudes. For example, a plot might show one
244 magnitude on the x-axis, a second on the y-axis, and plot statistics as a
245 function of signal-to-noise from a third magnitude.
247 The fluxes_default attribute contains commonly used configurations for
248 object tables. The "_err" suffix that a flux has an associated error
249 column that is needed for some calculation; it can and should be omitted
250 if the error column is unneeded. Some flux columns in reference catalogs
251 may not have an error at all, such as injection catalogs or truth catalogs
252 from simulations.
254 Notes
255 -----
256 Any tool that reads in flux columns and converts them to magnitudes can
257 derive from this class and use the _add_flux method to set the
258 necessary build actions in their own finalize() methods.
259 """
261 fluxes_default = FluxesDefaultConfig(
262 bulge_err=FluxConfig(
263 key_flux="{band}_cModel_devFlux",
264 key_flux_error="{band}_cModel_devFluxErr",
265 name_flux="CModel Bulge",
266 name_flux_short="bulge_cModel",
267 ),
268 cmodel_err=FluxConfig(
269 key_flux="{band}_cModelFlux",
270 key_flux_error="{band}_cModelFluxErr",
271 name_flux="CModel",
272 name_flux_short="cModel",
273 ),
274 disk_err=FluxConfig(
275 key_flux="{band}_cModel_expFlux",
276 key_flux_error="{band}_cModel_expFluxErr",
277 name_flux="CModel Disk",
278 name_flux_short="disk_cModel",
279 ),
280 exponential_err=FluxConfig(
281 key_flux="{band}_exponentialFlux",
282 key_flux_error="{band}_exponentialFluxErr",
283 name_flux="Exponential",
284 name_flux_short="exp",
285 ),
286 gaap1p0_err=FluxConfig(
287 key_flux="{band}_gaap1p0Flux",
288 key_flux_error="{band}_gaap1p0FluxErr",
289 name_flux='GAaP 1.0"',
290 name_flux_short="gaap_1p0",
291 ),
292 kron_err=FluxConfig(
293 key_flux="{band}_kronFlux",
294 key_flux_error="{band}_kronFluxErr",
295 name_flux="Kron",
296 name_flux_short="kron",
297 ),
298 psf_err=FluxConfig(
299 key_flux="{band}_psfFlux",
300 key_flux_error="{band}_psfFluxErr",
301 name_flux="PSF",
302 name_flux_short="psf",
303 ),
304 ref_matched=FluxConfig(
305 key_flux="refcat_flux_{band}",
306 key_flux_error=None,
307 name_flux="Reference",
308 name_flux_short="ref",
309 ),
310 sersic_err=FluxConfig(
311 key_flux="{band}_sersicFlux",
312 key_flux_error="{band}_sersicFluxErr",
313 name_flux="Sérsic",
314 name_flux_short="sersic",
315 ),
316 )
318 fluxes = ConfigDictField[str, FluxConfig]( # type: ignore
319 default={},
320 doc="Flux fields to convert to magnitudes",
321 )
323 def _add_flux(self, name: str, config: FluxConfig, band: str | None = None) -> str:
324 """Add requisite buildActions for a given flux.
326 Parameters
327 ----------
328 name
329 The name of the flux, without "flux_" prefix.
330 config
331 The configuration for the flux.
332 band
333 The name of the band. Default "{band}" assumes the this band is
334 the parameterized band.
336 Returns
337 -------
338 name
339 The name of the flux, suffixed by band if band is not None.
340 """
341 if band is None:
342 band = "{band}"
343 else:
344 name = f"{name}_{band}"
345 key_flux = config.key_flux_band(band=band)
346 name_flux = f"flux_{name}"
347 self._set_action(self.process.buildActions, name_flux, LoadVector, vectorKey=key_flux)
348 if config.key_flux_error is not None:
349 # Pre-emptively loaded for e.g. future S/N calculations
350 key_flux_err = config.key_flux_error_band(band=band)
351 self._set_action(
352 self.process.buildActions, f"flux_err_{name}", LoadVector, vectorKey=key_flux_err
353 )
354 self._set_action(self.process.buildActions, f"mag_{name}", ConvertFluxToMag, vectorKey=key_flux)
355 return name
357 def _config_mag(self, name_mag: str = "mag_x"):
358 attr = getattr(self, name_mag)
359 if attr not in self.fluxes:
360 raise KeyError(f"self.{name_mag}={attr} not in {self.fluxes}; was finalize called?")
361 return self.fluxes[attr]
363 def _finalize_mag(self, name_mag: str = "mag_x", prefix: str = "x"):
364 self._set_flux_default(name_mag)
365 attr = getattr(self, name_mag)
366 key_mag = f"mag_{attr}"
367 classes = self.get_classes()
368 for object_class in classes:
369 name_selector = self.get_name_attr_selector(object_class)
370 self._set_action(
371 self.process.filterActions,
372 self.get_name_attr_values(object_class, prefix=prefix),
373 DownselectVector,
374 vectorKey=key_mag,
375 selector=VectorSelector(vectorKey=name_selector),
376 )
378 def _set_action(self, target: ConfigurableActionStructField, name: str, action, *args, **kwargs):
379 """Set an action attribute on a target tool's struct field.
381 Parameters
382 ----------
383 target
384 The ConfigurableActionStructField to set an attribute on.
385 name
386 The name of the attribute to set.
387 action
388 The action class to set the attribute to.
389 args
390 Arguments to pass when initialization the action.
391 kwargs
392 Keyword arguments to pass when initialization the action.
393 """
394 if hasattr(target, name):
395 attr = getattr(target, name)
396 # Setting an attr to a different action is a logic error
397 assert isinstance(attr, action)
398 # Assert that the action's attributes are identical
399 for key, value in kwargs.items():
400 if value.__class__.__module__ == "__builtin__":
401 assert getattr(attr, key) == value
402 else:
403 setattr(target, name, action(*args, **kwargs))
405 def _set_flux_default(self, attr: str, band: str | None = None, name_mag: str | None = None) -> str:
406 """Set own config attr to appropriate string flux name.
408 Parameters
409 ----------
410 attr
411 The name of the attribute to set.
412 band
413 The name of the band to pass to _add_flux.
414 name_mag
415 The name of the magnitude to configure. If None, self must already
416 have an attr, and name_mag is set to the attr's value.
417 """
418 name_mag_is_none = name_mag is None
419 if name_mag_is_none:
420 name_mag = getattr(self, attr)
421 if name_mag is None:
422 raise RuntimeError(
423 f"{self=}.{attr=} cannot be None. If this is a config field, it must be set."
424 )
425 complete = name_mag in self.fluxes
426 else:
427 complete = hasattr(self, attr)
428 # Do nothing if already set - may have been called 2+ times
429 if not complete:
430 name_found = None
431 drop_err = False
432 # Check if the name with errors is a configured default
433 if name_mag.endswith("_err"):
434 if hasattr(self.fluxes_default, name_mag):
435 name_found = name_mag
436 else:
437 if hasattr(self.fluxes_default, name_mag):
438 name_found = name_mag
439 # Check if a config with errors exists but not without
440 elif hasattr(self.fluxes_default, f"{name_mag}_err"):
441 name_found = f"{name_mag}_err"
442 # Don't load the errors - no _err suffix == unneeded
443 drop_err = True
444 if name_found:
445 # Copy the config - we don't want to edit in place
446 # Other instances may use them
447 value = copy.copy(getattr(self.fluxes_default, name_found))
448 # Ensure no unneeded error columns are loaded
449 if drop_err:
450 value.key_flux_error = None
451 self.fluxes[name_found] = value
452 name_found = self._add_flux(name=name_found, config=value, band=band)
453 else:
454 raise RuntimeError(
455 f"flux={name_mag} not defined in self.fluxes={self.fluxes}"
456 f" and no default configuration found"
457 )
458 if name_mag_is_none and (name_mag != name_found):
459 # Essentially appends _err to the name if needed
460 setattr(self, attr, name_found)
462 def finalize(self):
463 super().finalize()
464 for key, config in self.fluxes.items():
465 self._add_flux(name=key, config=config)
468class MagnitudeXTool(MagnitudeTool):
469 """A Tool for metrics/plots with a magnitude as the dependent variable."""
471 mag_x = Field[str](
472 doc="Flux (magnitude) FluxConfig key (in self.fluxes) to bin metrics by or plot on x-axis",
473 )
475 @property
476 def config_mag_x(self):
477 return self._config_mag()
479 def finalize(self):
480 super().finalize()
481 self._finalize_mag()
484class SizeConfig(Config):
485 """Configuration for size vector(s) to be loaded and possibly plotted."""
487 has_moments = Field[bool](doc="Whether this size measure is stored as 2D moments.", default=True)
488 key_size = Field[str](
489 doc="Size column(s) to compute/plot, including moment suffix as {suffix}.",
490 )
491 log10_size = Field[bool](
492 default=True,
493 doc="Whether to compute/plot)log10 of the sizes.",
494 )
495 name_size = Field[str](
496 default="size",
497 doc="Name of the size (e.g. for axis labels).",
498 )
499 scale_size = Field[float](
500 default=1.0,
501 doc="Factor to scale sizes (multiply) by.",
502 )
503 unit_size = Field[str](
504 default="arcsec",
505 doc="Unit for sizes.",
506 )
508 def modify_action(self, action: VectorAction) -> VectorAction:
509 if self.log10_size:
510 action = Log10Vector(actionA=action)
511 return action
514class SizeDefaultConfig(Config):
515 bulge = ConfigField[SizeConfig](doc="Bulge model size config.")
516 disk = ConfigField[SizeConfig](doc="Disk model size config.")
517 exponential = ConfigField[SizeConfig](doc="Exponential model effective radius config")
518 moments = ConfigField[SizeConfig](doc="Second moments size config.")
519 sersic = ConfigField[SizeConfig](doc="Sersic model effective radius config")
520 shape_slot = ConfigField[SizeConfig](doc="Shape slot size config.")
523class MomentsConfig(Config):
524 """Configuration for moment field suffixes."""
526 xx = Field[str](doc="Suffix for the x/xx moments.", default="xx")
527 xy = Field[str](doc="Suffix for the rho value/xy moments.", default="xy")
528 yy = Field[str](doc="Suffix for the y/yy moments.", default="yy")
531class SizeTool(ObjectClassTool):
532 """Compute various object size definitions in linear or log space.
534 This tool is designed to make it easy to configure a size based on the
535 definition of the size and the configuration of the columns that it is
536 read from.
538 It currently only supports a single size but may be refactored to
539 support arbitrary sizes, like MagnitudeTool.
540 """
542 attr_prefix = Field[str](doc="Prefix to prepend to size names as attrs", default="size_", optional=False)
543 config_moments = ConfigField[MomentsConfig](
544 doc="Configuration for moment field names", default=MomentsConfig
545 )
546 is_covariance = Field[bool](
547 doc="Whether this size has multiple fields as for a covariance matrix."
548 " If False, the XX/YY/XY terms are instead assumed to map to sigma_x/sigma_y/rho.",
549 default=True,
550 )
551 sizes_default = SizeDefaultConfig(
552 bulge=SizeConfig(
553 key_size="{band}_cModel_dev_reff_major",
554 name_size="CModel Bulge $R_{eff,major}$",
555 has_moments=False,
556 ),
557 disk=SizeConfig(
558 key_size="{band}_cModel_exp_reff_major",
559 name_size="CModel Disk $R_{eff,major}$",
560 has_moments=False,
561 ),
562 exponential=SizeConfig(
563 key_size="exponential_reff_major", name_size="Exponential $R_{eff,major}$", has_moments=False
564 ),
565 moments=SizeConfig(key_size="{band}_i{suffix}", name_size="Second moment radius"),
566 sersic=SizeConfig(
567 key_size="sersic_reff_major", name_size="Sérsic $R_{eff,major}$", has_moments=False
568 ),
569 shape_slot=SizeConfig(key_size="shape_{suffix}", name_size="Shape slot radius"),
570 )
571 size_type = ChoiceField[str](
572 doc="The type of size to calculate",
573 allowed={
574 "determinantRadius": "The (matrix) determinant radius from x/y moments.",
575 "traceRadius": "The (matrix) trace radius from x/y moments.",
576 "singleColumnSize": "A pre-computed size from a single column.",
577 },
578 optional=False,
579 )
580 size_y = Field[str](default=None, doc="Name of size field to plot on y axis.")
581 sizes = ConfigDictField[str, SizeConfig]( # type: ignore
582 default={},
583 doc="Size fields to add to build actions",
584 )
586 def _check_attr(self, name_size: str):
587 """Check if a buildAction has already been set."""
588 attr = self.get_attr_name(name_size)
589 if hasattr(self.process.buildActions, attr):
590 raise RuntimeError(f"Can't re-set size build action with already-used {attr=} from {name_size=}")
592 def _get_action_determinant(self, config):
593 action = CalcMomentSize(
594 colXx=config.key_size.format(suffix=self.config_moments.xx),
595 colYy=config.key_size.format(suffix=self.config_moments.yy),
596 colXy=config.key_size.format(suffix=self.config_moments.xy),
597 is_covariance=self.is_covariance,
598 )
599 return action
601 def _get_action_trace(self, config):
602 action = CalcMomentSize(
603 colXx=config.key_size.format(suffix=self.config_moments.xx),
604 colYy=config.key_size.format(suffix=self.config_moments.yy),
605 is_covariance=self.is_covariance,
606 )
607 return action
609 def _get_action_single_column(self, config):
610 action = LoadVector(vectorKey=config.key_size)
611 return action
613 def get_attr_name(self, name_size):
614 """Return the build action attribute for a size of a given name."""
615 return f"{self.attr_prefix}{name_size}"
617 def setDefaults(self):
618 super().setDefaults()
619 self.produce.plot.legendLocation = "lower left"
621 def finalize(self):
622 # A lazy check for whether finalize has already been called
623 classes = self.get_classes()
624 if hasattr(self.process.filterActions, self.get_name_attr_values(classes[0])):
625 return
626 super().finalize()
627 if not self.size_y:
628 raise ValueError("Must specify size_y")
629 elif self.size_y not in self.sizes:
630 if size_y := getattr(self.sizes_default, self.size_y, None):
631 self.sizes[self.size_y] = size_y
632 else:
633 raise RuntimeError(f"{self.size_y=} not found in {self.sizes=} or {self.sizes_default=}")
635 if self.size_type == "determinantRadius":
636 get_action = self._get_action_determinant
637 elif self.size_type == "traceRadius":
638 get_action = self._get_action_trace
639 elif self.size_type == "singleColumnSize":
640 get_action = self._get_action_single_column
641 else:
642 raise ValueError(f"Unsupported {self.size_type=}")
644 for name, config in self.sizes.items():
645 self._check_attr(name)
646 action = config.modify_action(
647 MultiplyVector(
648 actionA=get_action(config=config),
649 actionB=ConstantValue(value=config.scale_size),
650 )
651 )
652 setattr(self.process.buildActions, self.get_attr_name(name), action)
654 attr = self.get_attr_name(self.size_y)
655 classes = self.get_classes()
656 for object_class in classes:
657 setattr(
658 self.process.filterActions,
659 self.get_name_attr_values(object_class),
660 DownselectVector(
661 vectorKey=attr,
662 selector=VectorSelector(vectorKey=self.get_name_attr_selector(object_class)),
663 ),
664 )