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