Coverage for python / lsst / multiprofit / fitting / fit_catalog.py: 50%
106 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 02:00 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 02:00 -0800
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__ = ["CatalogExposureABC", "ColumnInfo", "CatalogFitterConfig"]
24from abc import ABC, abstractmethod
25from collections.abc import Iterable
26from typing import ClassVar
28from astropy.table import Table
29import astropy.units as u
30import lsst.pex.config as pexConfig
31import numpy as np
32import pydantic
34from ..componentconfig import GaussianComponentConfig, SersicComponentConfig
35from ..modeller import ModelFitConfig
36from ..utils import frozen_arbitrary_allowed_config
39class CatalogExposureABC(ABC):
40 """Interface for catalog-exposure pairs."""
42 # TODO: add get_exposure (with Any return type?)
44 @abstractmethod
45 def get_catalog(self) -> Iterable:
46 """Return a row-iterable catalog covering an exposure."""
49class ColumnInfo(pydantic.BaseModel):
50 """Metadata for a column in a catalog."""
52 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config
54 dtype: str = pydantic.Field(title="Column data type name (numpy or otherwise)")
55 key: str = pydantic.Field(title="Column key (name)")
56 description: str = pydantic.Field("", title="Column description")
57 unit: u.UnitBase | None = pydantic.Field(None, title="Column unit (astropy)")
60class CatalogFitterConfig(pexConfig.Config):
61 """Configuration for generic MultiProFit fitting tasks."""
63 column_id = pexConfig.Field[str](default="id", doc="Catalog index column key")
64 compute_errors = pexConfig.ChoiceField[str](
65 default="INV_HESSIAN_BESTFIT",
66 doc="Whether/how to compute sqrt(variances) of each free parameter",
67 allowed={
68 "NONE": "no errors computed",
69 "INV_HESSIAN": "inverse hessian using noisy image as data",
70 "INV_HESSIAN_BESTFIT": "inverse hessian using best-fit model as data",
71 },
72 )
73 compute_errors_from_jacobian = pexConfig.Field[bool](
74 default=True,
75 doc="Whether to estimate the Hessian from the Jacobian first, with finite differencing as a backup",
76 )
77 compute_errors_no_covar = pexConfig.Field[bool](
78 default=True,
79 doc="Whether to compute parameter errors independently, ignoring covariances",
80 )
81 config_fit = pexConfig.ConfigField[ModelFitConfig](default=ModelFitConfig, doc="Fitter configuration")
82 fit_centroid = pexConfig.Field[bool](default=True, doc="Fit centroid parameters")
83 fit_linear_init = pexConfig.Field[bool](default=True, doc="Fit linear parameters after initialization")
84 fit_linear_final = pexConfig.Field[bool](default=True, doc="Fit linear parameters after optimization")
85 float_fill_value = pexConfig.Field[float](
86 default=np.nan, doc="Fill value for float fields when creating the output table."
87 )
88 flag_errors = pexConfig.DictField(
89 default={},
90 keytype=str,
91 itemtype=str,
92 doc="Flag column names to set, keyed by name of exception to catch",
93 )
94 integer_fill_value = pexConfig.Field[int](
95 default=-1, doc="Fill value for integer fields when creating the output table."
96 )
97 naming_scheme = pexConfig.ChoiceField[str](
98 doc="Naming scheme for column names",
99 allowed={
100 "default": "snake_case with {component_name}[_{band}]_{parameter}[_err]",
101 "camel": "CamelCase with {component_name}[_{band}]_{parameter}[Err]",
102 "lsst": "snake_case with [{band}_]{component_name}_{parameter}[Err]",
103 },
104 default="default",
105 )
106 prefix_column = pexConfig.Field[str](default="mpf_", doc="Column name prefix")
107 suffix_error = pexConfig.Field[str](
108 default="_err",
109 doc="Default suffix for error columns. Can be overridden by naming_scheme.",
110 )
112 _format_flux = {
113 "default": "{label}{band}_flux",
114 "lsst": "{band}_{label}Flux",
115 "camel": "{label}{band}Flux",
116 }
117 _key_cen = {"default": "_cen", "lsst": "_cen", "camel": "Cen"}
118 _key_reff = {"default": f"_{SersicComponentConfig._size_label}", "lsst": "_reff", "camel": "Reff"}
119 _key_rho = {"default": "_rho", "lsst": "_rho", "camel": "Rho"}
120 _key_sigma = {"default": f"_{GaussianComponentConfig._size_label}", "lsst": "_sigma", "camel": "Sigma"}
121 _key_sersicindex = {"default": "_sersic_index", "lsst": "sersic_index", "camel": "SersicIndex"}
122 _suffix_dec = {"default": "_dec", "lsst": "_dec", "camel": "Dec"}
123 _suffix_ra = {"default": "_ra", "lsst": "_ra", "camel": "Ra"}
124 _suffix_x = {"default": "_x", "lsst": "_x", "camel": "X"}
125 _suffix_y = {"default": "_y", "lsst": "_y", "camel": "Y"}
127 def _get_label(self, format_name: str, values: dict[str, str]) -> str:
128 """Get the label for part of a column name for a given format.
130 Parameters
131 ----------
132 format_name
133 The name of the format to get the label for.
134 values
135 The values of the name by format.
137 Returns
138 -------
139 label
140 The formatted label, if specified for that format, else the
141 value for the default format.
142 """
143 return values.get(format_name, values["default"])
145 def get_key_cen(self) -> str:
146 """Get the key for centroid columns."""
147 return self._get_label(self.naming_scheme, self._key_cen)
149 def get_key_flux(self, band: str, label: str = "") -> str:
150 """Get the key for a flux column.
152 Parameters
153 ----------
154 band
155 The band of the flux column.
156 label
157 A label for this flux, e.g. a component name.
159 Returns
160 -------
161 key_flux
162 The flux column key.
163 """
164 return self._get_label(self.naming_scheme, self._format_flux).format(band=band, label=label)
166 def get_key_reff(self) -> str:
167 """Get the key for Sersic effective radius columns."""
168 return self._get_label(self.naming_scheme, self._key_reff)
170 def get_key_rho(self) -> str:
171 """Get the key for ellipse rho columns."""
172 return self._get_label(self.naming_scheme, self._key_rho)
174 def get_key_sersicindex(self) -> str:
175 """Get the key for Sersic index columns."""
176 return self._get_label(self.naming_scheme, self._key_sersicindex)
178 def get_key_sigma(self) -> str:
179 """Get the key for Gaussian sigma columns."""
180 return self._get_label(self.naming_scheme, self._key_sigma)
182 def get_key_size(self, label_size: str) -> str:
183 """Get the key for a size column by its label.
185 Parameters
186 ----------
187 label_size
188 The label of the size, usually specified in a ComponentConfig.
190 Returns
191 -------
192 key_size
193 The size column key.
194 """
195 if label_size == GaussianComponentConfig._size_label:
196 return self._get_label(self.naming_scheme, self._key_sigma)
197 elif label_size == SersicComponentConfig._size_label:
198 return self._get_label(self.naming_scheme, self._key_reff)
199 return label_size
201 def get_prefixed_label(self, label: str, prefix: str) -> str:
202 """Get a prefixed label with redundant underscores removed.
204 Parameters
205 ----------
206 label
207 The label to format.
208 prefix
209 The prefix to prepend.
211 Returns
212 -------
213 label_prefixed
214 The prefixed label, with redundant underscores removed.
215 """
216 if label.startswith("_") and ((prefix == "") or (prefix[-1] == "_")):
217 return f"{prefix}{label[1:]}"
218 return f"{prefix}{label}"
220 def get_suffix_dec(self) -> str:
221 """Get the suffix for declination columns."""
222 return self._get_label(self.naming_scheme, self._suffix_dec)
224 def get_suffix_ra(self) -> str:
225 """Get the suffix for right ascension columns."""
226 return self._get_label(self.naming_scheme, self._suffix_ra)
228 def get_suffix_x(self) -> str:
229 """Get the suffix for x-axis columns."""
230 return self._get_label(self.naming_scheme, self._suffix_x)
232 def get_suffix_y(self) -> str:
233 """Get the suffix for y-axis columns."""
234 return self._get_label(self.naming_scheme, self._suffix_y)
236 def make_catalog(self, n_rows: int, **kwargs):
237 """Make a catalog with default-initialized column values.
239 Parameters
240 ----------
241 n_rows
242 The number of rows to create.
243 **kwargs
244 Keyword arguments to pass to self.schema.
246 Returns
247 -------
248 catalog
249 The initialized catalog.
250 columns
251 The columns as returned by self.schema.
252 """
253 columns = self.schema(**kwargs)
254 keys = [column.key for column in columns]
255 prefix = self.prefix_column
257 idx_flag_first = keys.index("unknown_flag")
258 idx_flag_last = idx_flag_first + len(self.flag_errors)
259 dtypes = [(f'{prefix if col.key != self.column_id else ""}{col.key}', col.dtype) for col in columns]
261 results = Table(np.empty(n_rows, dtype=dtypes))
262 for colname in results.colnames:
263 column = results[colname]
264 dtype = column.dtype
265 if (
266 value := (
267 self.float_fill_value
268 if np.issubdtype(dtype, np.floating)
269 else (self.integer_fill_value if np.issubdtype(dtype, np.integer) else None)
270 )
271 ) is not None:
272 column[:] = value
274 # Set nan-default flags to False instead
275 errors = []
276 for flag in columns[idx_flag_first : (idx_flag_last + 1)]:
277 column = results[f"{prefix}{flag.key}"]
278 column[:] = False
279 if not ((column.dtype == bool) and column.name.endswith("_flag")):
280 errors.append(f"{column.name=} should end with _flag and {column.dtype=} must be bool")
281 if errors:
282 errors.append(f"These may be logic errors in {self=}")
283 raise RuntimeError("\n".join(errors))
284 results.meta["config"] = self.toDict()
286 return results, columns
288 def schema(
289 self,
290 bands: list[str] | None = None,
291 ) -> list[ColumnInfo]:
292 """Return the schema as an ordered list of columns.
294 Parameters
295 ----------
296 bands
297 A list of band names to prefix band-dependent columns with.
298 Band prefixes should not be used if None.
300 Returns
301 -------
302 schema
303 An ordered list of ColumnInfo instances.
304 """
305 schema = [
306 ColumnInfo(key=self.column_id, dtype="i8"),
307 ColumnInfo(key="n_iter", dtype="i4"),
308 ColumnInfo(key="time_eval", dtype="f8", unit=u.s),
309 ColumnInfo(key="time_fit", dtype="f8", unit=u.s),
310 ColumnInfo(key="time_full", dtype="f8", unit=u.s),
311 ColumnInfo(key="chisq_reduced", dtype="f8"),
312 ColumnInfo(key="unknown_flag", dtype="bool"),
313 ]
314 schema.extend([ColumnInfo(key=key, dtype="bool") for key in self.flag_errors.keys()])
315 # Subclasses should always write out centroids even if not fitting
316 # They are helpful for reconstructing models
317 return schema