Coverage for python / lsst / multiprofit / componentconfig.py: 53%
155 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:58 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:58 +0000
1# This file is part of multiprofit.
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/>.
22__all__ = [
23 "ParameterConfig",
24 "FluxFractionParameterConfig",
25 "FluxParameterConfig",
26 "CentroidConfig",
27 "ComponentData",
28 "Fluxes",
29 "EllipticalComponentConfig",
30 "GaussianComponentConfig",
31 "SersicIndexParameterConfig",
32 "SersicComponentConfig",
33]
35from abc import abstractmethod
36import string
37from typing import Any, ClassVar
39import lsst.gauss2d.fit as g2f
40import lsst.pex.config as pexConfig
41import pydantic
43from .limits import limits_ref
44from .priors import ShapePriorConfig
45from .transforms import transforms_ref
46from .utils import frozen_arbitrary_allowed_config
49class ParameterConfig(pexConfig.Config):
50 """Configuration for a parameter."""
52 fixed = pexConfig.Field[bool](default=False, doc="Whether parameter is fixed or not (free)")
53 value_initial = pexConfig.Field[float](default=0, doc="Initial value")
56class FluxParameterConfig(ParameterConfig):
57 """Configuration for flux parameters (IntegralParameterD).
59 The safest initial value for a flux is 1.0, because if it's set to zero,
60 linear fitting will not work correctly initially.
61 """
63 def setDefaults(self) -> None:
64 super().setDefaults()
65 self.value_initial = 1.0
68class FluxFractionParameterConfig(ParameterConfig):
69 """Configuration for flux fraction parameters (ProperFractionParameterD).
71 The safest initial value for a flux fraction is 0.5, because if it's set
72 to one, downstream fractions will be zero, while if it's set to zero,
73 linear fitting will not work correctly initially.
74 """
76 def setDefaults(self) -> None:
77 super().setDefaults()
78 self.value_initial = 0.5
81class CentroidConfig(pexConfig.Config):
82 """Configuration for a component centroid."""
84 x = pexConfig.ConfigField[ParameterConfig](doc="The x-axis centroid configuration")
85 y = pexConfig.ConfigField[ParameterConfig](doc="The y-axis centroid configuration")
87 def make_centroid(self) -> g2f.CentroidParameters:
88 cen_x, cen_y = (
89 type_param(config.value_initial, fixed=config.fixed, limits=g2f.LimitsD())
90 for (config, type_param) in ((self.x, g2f.CentroidXParameterD), (self.y, g2f.CentroidYParameterD))
91 )
92 centroid = g2f.CentroidParameters(x=cen_x, y=cen_y)
93 return centroid
96class ComponentData(pydantic.BaseModel):
97 """Dataclass for a Component config."""
99 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config
101 component: g2f.Component = pydantic.Field(title="The component instance")
102 integral_model: g2f.IntegralModel = pydantic.Field(title="The component's integral_model")
103 priors: list[g2f.Prior] = pydantic.Field(title="The priors associated with the component")
106Fluxes = dict[g2f.Channel, float]
109class EllipticalComponentConfig(ShapePriorConfig):
110 """Configuration for an elliptically-symmetric component.
112 This class can be initialized but cannot implement make_component.
113 """
115 fluxfrac = pexConfig.ConfigField[FluxFractionParameterConfig](
116 doc="Fractional flux parameter(s) config",
117 default=None,
118 )
119 flux = pexConfig.ConfigField[FluxParameterConfig](
120 doc="Flux parameter(s) config",
121 default=FluxParameterConfig,
122 )
124 rho = pexConfig.ConfigField[ParameterConfig](doc="Rho parameter config")
125 size_x = pexConfig.ConfigField[ParameterConfig](doc="x-axis size parameter config")
126 size_y = pexConfig.ConfigField[ParameterConfig](doc="y-axis size parameter config")
127 transform_flux_name = pexConfig.Field[str](
128 doc="The name of the reference transform for flux parameters",
129 default="log10",
130 optional=True,
131 )
132 transform_fluxfrac_name = pexConfig.Field[str](
133 doc="The name of the reference transform for flux fraction parameters",
134 default="logit_fluxfrac",
135 optional=True,
136 )
137 transform_rho_name = pexConfig.Field[str](
138 doc="The name of the reference transform for rho parameters",
139 default="logit_rho",
140 optional=True,
141 )
142 transform_size_name = pexConfig.Field[str](
143 doc="The name of the reference transform for size parameters",
144 default="log10",
145 optional=True,
146 )
148 def format_label(self, label: str, name_channel: str) -> str:
149 """Format a label for a band-dependent parameter.
151 Parameters
152 ----------
153 label
154 The label to format.
155 name_channel
156 The name of the channel to format with.
158 Returns
159 -------
160 label_formmated
161 The formatted label.
162 """
163 label_formatted = string.Template(label).safe_substitute(
164 type_component=self.get_type_name(),
165 name_channel=name_channel,
166 )
167 return label_formatted
169 @staticmethod
170 def get_integral_label_default() -> str:
171 """Return the default integral label."""
172 return "${type_component} ${name_channel}-band"
174 @abstractmethod
175 def get_size_label(self) -> str:
176 """Return the label for the component's size parameters."""
177 raise NotImplementedError("EllipticalComponent does not implement get_size_label")
179 @abstractmethod
180 def get_type_name(self) -> str:
181 """Return a descriptive component name."""
182 raise NotImplementedError("EllipticalComponent does not implement get_type_name")
184 def get_transform_fluxfrac(self) -> g2f.TransformD | None:
185 return transforms_ref[self.transform_fluxfrac_name] if self.transform_fluxfrac_name else None
187 def get_transform_flux(self) -> g2f.TransformD | None:
188 return transforms_ref[self.transform_flux_name] if self.transform_flux_name else None
190 def get_transform_rho(self) -> g2f.TransformD | None:
191 return transforms_ref[self.transform_rho_name] if self.transform_rho_name else None
193 def get_transform_size(self) -> g2f.TransformD | None:
194 return transforms_ref[self.transform_size_name] if self.transform_size_name else None
196 @abstractmethod
197 def make_component(
198 self,
199 centroid: g2f.CentroidParameters,
200 integral_model: g2f.IntegralModel,
201 ) -> ComponentData:
202 """Make a Component reflecting the current configuration.
204 Parameters
205 ----------
206 centroid
207 Centroid parameters for the component.
208 integral_model
209 The integral_model for this component.
211 Returns
212 -------
213 component_data
214 An appropriate ComponentData including the initialized component.
216 Notes
217 -----
218 The default `gauss2d.fit.LinearIntegralModel` can be populated with
219 unit fluxes (`gauss2d.fit.IntegralParameterD` instances) to prepare
220 for linear least squares fitting.
221 """
222 raise NotImplementedError("EllipticalComponent cannot not implement make_component")
224 def make_gaussianparametricellipse(self) -> g2f.GaussianParametricEllipse:
225 """Make a GaussianParametericEllipse from this object's configuration.
227 Returns
228 -------
229 ellipse
230 The configured ellipse.
231 """
232 transform_size = self.get_transform_size()
233 transform_rho = self.get_transform_rho()
234 ellipse = g2f.GaussianParametricEllipse(
235 sigma_x=g2f.SigmaXParameterD(
236 self.size_x.value_initial, transform=transform_size, fixed=self.size_x.fixed
237 ),
238 sigma_y=g2f.SigmaYParameterD(
239 self.size_y.value_initial, transform=transform_size, fixed=self.size_y.fixed
240 ),
241 rho=g2f.RhoParameterD(self.rho.value_initial, transform=transform_rho, fixed=self.rho.fixed),
242 )
243 return ellipse
245 def make_fluxfrac_parameter(
246 self, value: float | None, label: str | None = None, **kwargs: Any
247 ) -> g2f.ProperFractionParameterD:
248 parameter = g2f.ProperFractionParameterD(
249 value if value is None else self.fluxfrac.value_initial,
250 fixed=self.fluxfrac.fixed,
251 transform=self.get_transform_fluxfrac(),
252 label=label if label is not None else "",
253 **kwargs,
254 )
255 return parameter
257 def make_flux_parameter(
258 self, value: float | None, label: str | None = None, **kwargs: Any
259 ) -> g2f.IntegralParameterD:
260 """Make a single IntegralParameterD from this object's configuration.
262 Parameters
263 ----------
264 value
265 The initial value. Default is self.flux.value_initial.
266 label
267 The label for the parameter. Default empty string.
268 **kwargs
269 Other keyword arguments to pass to the IntegralParameterD
270 constructor.
272 Returns
273 -------
274 param
275 The constructed IntegralParameterD.
276 """
277 parameter = g2f.IntegralParameterD(
278 value if value is not None else self.flux.value_initial,
279 fixed=self.flux.fixed,
280 transform=self.get_transform_flux(),
281 label=label if label is not None else "",
282 **kwargs,
283 )
284 return parameter
286 def make_linear_integral_model(
287 self, fluxes: Fluxes, label_integral: str | None = None, **kwargs: Any
288 ) -> g2f.IntegralModel:
289 """Make an lsst.gauss2d.fit.LinearIntegralModel for this component.
291 Parameters
292 ----------
293 fluxes
294 Configurations, including initial values, for the flux
295 parameters by channel.
296 label_integral
297 A label to apply to integral parameters. Can reference the
298 relevant channel with e.g. {channel.name}.
299 **kwargs
300 Additional keyword arguments to pass to make_flux_parameter.
301 Some parameters cannot be overriden from their configs.
303 Returns
304 -------
305 integral_model
306 The requested lsst.gauss2d.fit.IntegralModel.
307 """
308 if label_integral is None:
309 label_integral = self.get_integral_label_default()
310 integral_model = g2f.LinearIntegralModel(
311 [
312 (
313 channel,
314 self.make_flux_parameter(
315 flux,
316 label=self.format_label(label_integral, name_channel=channel.name),
317 **kwargs,
318 ),
319 )
320 for channel, flux in fluxes.items()
321 ]
322 )
323 return integral_model
325 @staticmethod
326 def set_size_x(component: g2f.EllipticalComponent, size_x: float) -> None:
327 """Set the x-axis size parameter value for a component.
329 Parameters
330 ----------
331 component
332 The component to set the size for.
333 size_x
334 The value to set.
335 """
336 component.ellipse.sigma_x = size_x
338 @staticmethod
339 def set_size_y(component: g2f.EllipticalComponent, size_y: float) -> None:
340 """Set the y-axis size parameter value for a component.
342 Parameters
343 ----------
344 component
345 The component to set the size for.
346 size_y
347 The value to set.
348 """
349 component.ellipse.sigma_y = size_y
351 @staticmethod
352 def set_rho(component: g2f.EllipticalComponent, rho: float) -> None:
353 """Set the rho parameter value for a component.
355 Parameters
356 ----------
357 component
358 The component to set the size for.
359 rho
360 The value to set.
361 """
362 component.ellipse.rho = rho
365class GaussianComponentConfig(EllipticalComponentConfig):
366 """Configuration for an lsst.gauss2d.fit Gaussian component."""
368 _size_label = "sigma"
370 transform_frac_name = pexConfig.Field[str](
371 doc="The name of the reference transform for flux fraction parameters",
372 default="log10",
373 optional=True,
374 )
376 def get_size_label(self) -> str:
377 return self._size_label
379 def get_type_name(self) -> str:
380 return "Gaussian"
382 def make_component(
383 self,
384 centroid: g2f.CentroidParameters,
385 integral_model: g2f.IntegralModel,
386 ) -> ComponentData:
387 ellipse = self.make_gaussianparametricellipse()
388 prior = self.make_shape_prior(ellipse)
389 component_data = ComponentData(
390 component=g2f.GaussianComponent(
391 centroid=centroid,
392 ellipse=ellipse,
393 integral=integral_model,
394 ),
395 integral_model=integral_model,
396 priors=[] if prior is None else [prior],
397 )
398 return component_data
401class SersicIndexParameterConfig(ParameterConfig):
402 """Configuration for an lsst.gauss2d.fit Sersic index parameter."""
404 prior_mean = pexConfig.Field[float](doc="Mean for the prior (untransformed)", default=1.0, optional=True)
405 prior_stddev = pexConfig.Field[float](doc="Std. dev. for the prior", default=0.5, optional=True)
406 prior_transformed = pexConfig.Field[float](
407 doc="Whether the prior should be in transformed values",
408 default=True,
409 )
411 def make_prior(self, param: g2f.SersicIndexParameterD) -> g2f.Prior | None:
412 """Make a Gaussian prior for a given SersicIndexParameterD.
414 Parameters
415 ----------
416 param
417 The parameter to make a prior for.
419 Returns
420 -------
421 prior
422 The prior object, set according to the configuration, or none if
423 the required config parameters are None.
424 """
425 if self.prior_mean is not None:
426 mean = param.transform.forward(self.prior_mean) if self.prior_transformed else self.prior_mean
427 stddev = (
428 (
429 param.transform.forward(self.prior_mean + self.prior_stddev / 2.0)
430 - param.transform.forward(self.prior_mean - self.prior_stddev / 2.0)
431 )
432 if self.prior_transformed
433 else self.prior_stddev
434 )
435 return g2f.GaussianPrior(
436 param=param,
437 mean=mean,
438 stddev=stddev,
439 transformed=self.prior_transformed,
440 )
441 return None
443 def setDefaults(self) -> None:
444 self.value_initial = 0.5
446 def validate(self) -> None:
447 super().validate()
448 if self.prior_mean is not None:
449 if not self.prior_mean > 0.0:
450 raise ValueError("Sersic index prior mean must be > 0")
451 if not self.prior_stddev > 0.0:
452 raise ValueError("Sersic index prior std. dev. must be > 0")
455class SersicComponentConfig(EllipticalComponentConfig):
456 """Configuration for an lsst.gauss2d.fit Sersic component.
458 Notes
459 -----
460 make_component will return a `ComponentData` with an
461 `lsst.gauss2d.fit.GaussianComponent` if the Sersic index is fixed at 0.5,
462 or an `lsst.gauss2d.fit.SersicMixComponent` otherwise.
463 """
465 _interpolator_class_default = (
466 g2f.GSLSersicMixInterpolator
467 if hasattr(g2f, "GSLSersicMixInterpolator")
468 else g2f.LinearSersicMixInterpolator
469 )
470 _interpolators: dict[int, g2f.SersicMixInterpolator] = {
471 4: _interpolator_class_default(4),
472 8: _interpolator_class_default(8),
473 }
474 _size_label = "reff"
476 order = pexConfig.ChoiceField[int](doc="Sersic mix order", allowed={4: "Four", 8: "Eight"}, default=4)
477 sersic_index = pexConfig.ConfigField[SersicIndexParameterConfig](doc="Sersic index config")
479 def get_interpolator(self, order: int) -> g2f.SersicMixInterpolator:
480 """Get the best available interpolator for a given order.
482 Parameters
483 ----------
484 order
485 The order of the desired interpolator.
487 Returns
488 -------
489 interpolator
490 An interpolator of the requested order.
491 """
492 return self._interpolators.get(
493 order,
494 (
495 g2f.GSLSersicMixInterpolator
496 if hasattr(g2f, "GSLSersicMixInterpolator")
497 else g2f.LinearSersicMixInterpolator
498 )(order=order),
499 )
501 def get_size_label(self) -> str:
502 return self._size_label
504 def get_type_name(self) -> str:
505 is_gaussian_fixed = self.is_gaussian_fixed()
506 return f"{'Gaussian (fixed Sersic)' if is_gaussian_fixed else 'Sersic'}"
508 def is_gaussian_fixed(self) -> bool:
509 """Return True if the Sersic index is fixed at 0.5."""
510 return self.sersic_index.value_initial == 0.5 and self.sersic_index.fixed
512 def make_component(
513 self,
514 centroid: g2f.CentroidParameters,
515 integral_model: g2f.IntegralModel,
516 ) -> ComponentData:
517 is_gaussian_fixed = self.is_gaussian_fixed()
518 transform_size = self.get_transform_size()
519 transform_rho = self.get_transform_rho()
520 if is_gaussian_fixed:
521 ellipse = self.make_gaussianparametricellipse()
522 component = g2f.GaussianComponent(
523 centroid=centroid,
524 ellipse=ellipse,
525 integral=integral_model,
526 )
527 priors = []
528 else:
529 ellipse = g2f.SersicParametricEllipse(
530 size_x=g2f.ReffXParameterD(
531 self.size_x.value_initial, transform=transform_size, fixed=self.size_x.fixed
532 ),
533 size_y=g2f.ReffYParameterD(
534 self.size_y.value_initial, transform=transform_size, fixed=self.size_y.fixed
535 ),
536 rho=g2f.RhoParameterD(self.rho.value_initial, transform=transform_rho, fixed=self.rho.fixed),
537 )
538 sersic_index = g2f.SersicMixComponentIndexParameterD(
539 value=self.sersic_index.value_initial,
540 fixed=self.sersic_index.fixed,
541 transform=transforms_ref["logit_sersic"] if not self.sersic_index.fixed else None,
542 interpolator=self.get_interpolator(order=self.order),
543 limits=limits_ref["n_ser_multigauss"],
544 )
545 component = g2f.SersicMixComponent(
546 centroid=centroid,
547 ellipse=ellipse,
548 integral=integral_model,
549 sersicindex=sersic_index,
550 )
551 prior = self.sersic_index.make_prior(sersic_index) if not sersic_index.fixed else None
552 priors = [prior] if prior else []
553 prior = self.make_shape_prior(ellipse)
554 if prior:
555 priors.append(prior)
556 return ComponentData(
557 component=component,
558 integral_model=integral_model,
559 priors=priors,
560 )
562 def validate(self) -> None:
563 super().validate()