Coverage for python/lsst/multiprofit/fitting/fit_psf.py: 15%

298 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:36 -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/>. 

21 

22__all__ = [ 

23 "CatalogExposurePsfABC", 

24 "CatalogPsfFitterConfig", 

25 "CatalogPsfFitterConfigData", 

26 "CatalogPsfFitter", 

27] 

28 

29from abc import abstractmethod 

30from functools import cached_property 

31import logging 

32import time 

33from typing import Any, ClassVar, Mapping, Type 

34 

35import astropy 

36import lsst.gauss2d as g2 

37import lsst.gauss2d.fit as g2f 

38import lsst.pex.config as pexConfig 

39from lsst.utils.logging import PeriodicLogger 

40import numpy as np 

41import pydantic 

42 

43from ..componentconfig import ( 

44 Fluxes, 

45 FluxFractionParameterConfig, 

46 FluxParameterConfig, 

47 GaussianComponentConfig, 

48 ParameterConfig, 

49) 

50from ..errors import PsfRebuildFitFlagError 

51from ..modeller import FitInputsDummy, LinearGaussians, Modeller, make_psf_model_null 

52from ..sourceconfig import ComponentConfigs, ComponentGroupConfig, SourceConfig 

53from ..utils import frozen_arbitrary_allowed_config, get_params_uniq 

54from .fit_catalog import CatalogExposureABC, CatalogFitterConfig, ColumnInfo 

55 

56SourceRow = astropy.table.Row | Mapping[str, Any] 

57 

58 

59class CatalogPsfFitterConfig(CatalogFitterConfig): 

60 """Configuration for MultiProFit PSF image fitter.""" 

61 

62 model = pexConfig.ConfigField[SourceConfig]( 

63 default=SourceConfig( 

64 component_groups={ 

65 "": ComponentGroupConfig( 

66 components_gauss={ 

67 "gauss1": GaussianComponentConfig( 

68 size_x=ParameterConfig(value_initial=1.5), 

69 size_y=ParameterConfig(value_initial=1.5), 

70 fluxfrac=FluxFractionParameterConfig(value_initial=0.5), 

71 flux=FluxParameterConfig(value_initial=1.0, fixed=True), 

72 ), 

73 "gauss2": GaussianComponentConfig( 

74 size_x=ParameterConfig(value_initial=3.0), 

75 size_y=ParameterConfig(value_initial=3.0), 

76 fluxfrac=FluxFractionParameterConfig(value_initial=1.0, fixed=True), 

77 ), 

78 }, 

79 is_fractional=True, 

80 ) 

81 } 

82 ), 

83 doc="PSF model configuration", 

84 ) 

85 prior_axrat_mean = pexConfig.Field[float](default=0.95, doc="Mean for axis ratio prior") 

86 

87 def make_psf_model( 

88 self, 

89 component_group_fluxes: list[list[Fluxes]] | None = None, 

90 ) -> [g2f.PsfModel, list[g2f.Prior]]: 

91 """Make a PsfModel object for a given source. 

92 

93 Parameters 

94 ---------- 

95 component_group_fluxes 

96 Initial fluxes for each constituent ComponentGroup. 

97 

98 Returns 

99 ------- 

100 psf_model 

101 The rebuilt PSF model. 

102 

103 Notes 

104 ----- 

105 This function does not initialize the PSF model. 

106 """ 

107 if component_group_fluxes is None: 

108 channels = (g2f.Channel.NONE,) 

109 component_group_fluxes = [ 

110 component_group.get_fluxes_default( 

111 channels=channels, 

112 component_configs=component_group.get_component_configs(), 

113 is_fractional=component_group.is_fractional, 

114 ) 

115 for component_group in self.model.component_groups.values() 

116 ] 

117 

118 psf_model, _ = self.model.make_psf_model(component_group_fluxes=component_group_fluxes) 

119 return psf_model 

120 

121 def schema_configurable(self) -> list[ColumnInfo]: 

122 columns = [] 

123 if self.config_fit.eval_residual: 

124 columns.append(ColumnInfo(key="n_eval_jac", dtype="i4")) 

125 return columns 

126 

127 def schema( 

128 self, 

129 bands: list[str] | None = None, 

130 ) -> list[ColumnInfo]: 

131 """Return the schema as an ordered list of columns. 

132 

133 Parameters 

134 ---------- 

135 bands 

136 The bands to add band-dependent columns for. 

137 """ 

138 if bands is not None: 

139 if len(bands) != 1: 

140 raise ValueError("CatalogPsfFitter must have exactly one band") 

141 schema = super().schema(bands) 

142 parameters = CatalogPsfFitterConfigData(config=self).parameters 

143 unit_size = "pix" 

144 units = { 

145 g2f.ReffXParameterD: unit_size, 

146 g2f.ReffYParameterD: unit_size, 

147 g2f.SizeXParameterD: unit_size, 

148 g2f.SizeYParameterD: unit_size, 

149 } 

150 schema.extend( 

151 [ 

152 ColumnInfo(key=key, dtype="f8", unit=units.get(type(param))) 

153 for key, param in parameters.items() 

154 ] 

155 ) 

156 schema.extend(self.schema_configurable()) 

157 

158 return schema 

159 

160 def setDefaults(self) -> None: 

161 self.prefix_column = "mpf_psf_" 

162 self.compute_errors = "NONE" 

163 

164 

165class CatalogPsfFitterConfigData(pydantic.BaseModel): 

166 """A PSF fit configuration that can initialize models and images thereof. 

167 

168 This class relies on cached properties being computed once, mostly shortly 

169 after initialization. Therefore, it and the config field must be frozen to 

170 ensure that the model remains unchanged. 

171 """ 

172 

173 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

174 

175 config: CatalogPsfFitterConfig = pydantic.Field(title="A CatalogPsfFitterConfig to be frozen") 

176 

177 @pydantic.field_validator("config") 

178 @classmethod 

179 def validate_config(cls, v: CatalogPsfFitterConfig) -> CatalogPsfFitterConfig: 

180 v.validate() 

181 return v 

182 

183 @cached_property 

184 def components(self) -> dict[str, g2f.Component]: 

185 """Return the components of the PSF model by name.""" 

186 components = self.psf_model.components 

187 names = self.component_configs.keys() 

188 if len(components) != len(names): 

189 raise RuntimeError(f"{len(components)=} != {len(names)=}") 

190 components_names = {name: component for name, component in zip(names, components)} 

191 return components_names 

192 

193 @cached_property 

194 def component_configs(self) -> ComponentConfigs: 

195 """Return the config for each component in the PSF model.""" 

196 return self.config.model.get_component_configs() 

197 

198 @cached_property 

199 def componentgroup_configs(self) -> dict[str, ComponentGroupConfig]: 

200 """Return the config for each component group in the PSF model 

201 by name. 

202 """ 

203 return {k: v for k, v in self.config.model.component_groups.items()} 

204 

205 def init_psf_model( 

206 self, 

207 params: SourceRow, 

208 ) -> None: 

209 """Initialize the PSF model for a single source. 

210 

211 Parameters 

212 ---------- 

213 params : astropy.table.Row | typing.Mapping[str, typing.Any] 

214 A mapping with parameter values for the best-fit PSF model at the 

215 centroid of a single source. 

216 """ 

217 # TODO: Improve _flag checking (add a total _flag column) 

218 for flag in (col for col in params.keys() if col.endswith("_flag")): 

219 if params[flag]: 

220 raise PsfRebuildFitFlagError(f"Failed to rebuild PSF; {flag} set") 

221 

222 for name, param in self.parameters.items(): 

223 param.value = params[f"{self.config.prefix_column}{name}"] 

224 

225 def model_post_init(self, __context: Any) -> None: 

226 self.config.freeze() 

227 n_component_configs = len(self.component_configs) 

228 n_components = len(self.psf_model.components) 

229 if n_components != n_component_configs: 

230 raise AssertionError(f"{n_components=} != {n_component_configs=}") 

231 

232 @cached_property 

233 def parameters(self) -> dict[str, g2f.ParameterD]: 

234 """Return the free parameters for the PSF model by name.""" 

235 parameters = {} 

236 config = self.config 

237 has_prefix_group = config.model.has_prefix_group() 

238 components = self.psf_model.components 

239 idx_comp_first = 0 

240 

241 label_cen = config.get_key_cen() 

242 label_rho = config.get_key_rho() 

243 label_flux = config.get_key_flux("") 

244 label_fluxfrac = f"{label_flux}frac" 

245 suffix_x, suffix_y = config.get_suffix_x(), config.get_suffix_y() 

246 

247 # Iterate over each component group 

248 for name_group, config_group in self.componentgroup_configs.items(): 

249 prefix_group = f"{name_group}_" if has_prefix_group else "" 

250 is_fractional = config_group.is_fractional 

251 # Does this group have independent centroids? 

252 # Should generally be False if not is_fractional, since there is 

253 # little reason to make a group otherwise. 

254 multicen = len(config_group.centroids) > 1 

255 configs_comp = config_group.get_component_configs() 

256 idx_last = len(configs_comp) - 1 

257 n_params_flux_frac = 0 

258 

259 # Iterate over each component in the group 

260 for idx_comp_group, (name_comp, config_comp) in enumerate(configs_comp.items()): 

261 # The last component needs special handling if is_fractional 

262 is_last = idx_comp_group == idx_last 

263 component = components[idx_comp_first + idx_comp_group] 

264 prefix_comp = f"{prefix_group}{name_comp}" 

265 key_size = config.get_prefixed_label( 

266 config.get_key_size(config_comp.get_size_label()), 

267 prefix_comp, 

268 ) 

269 key_rho = config.get_prefixed_label(label_rho, prefix_comp) 

270 

271 # Give the centroid parameters an appropriate prefix 

272 if multicen or (idx_comp_group == 0): 

273 prefix_cen = prefix_comp if multicen else prefix_group 

274 # Avoid redundant -underscores if there's nothing to prefix 

275 # or an existing prefix starting with an underscore 

276 key_cen = config.get_prefixed_label(label_cen, prefix_cen) 

277 parameters[f"{key_cen}{suffix_x}"] = component.centroid.x_param 

278 parameters[f"{key_cen}{suffix_y}"] = component.centroid.y_param 

279 # Add each free shape parameter 

280 if not config_comp.size_x.fixed: 

281 parameters[f"{key_size}{suffix_x}"] = component.ellipse.size_x_param 

282 if not config_comp.size_y.fixed: 

283 parameters[f"{key_size}{suffix_y}"] = component.ellipse.size_y_param 

284 if not config_comp.rho.fixed: 

285 parameters[key_rho] = component.ellipse.rho_param 

286 

287 # TODO: return this to component.integralmodel 

288 # when binding for g2f.FractionalIntegralModel is fixed 

289 params_flux = get_params_uniq(component, fixed=False, nonlinear=False) 

290 # Fractional models should have one fixed flux parameter 

291 # (with value=1), and hence an empty params_flux 

292 has_params_flux = not config_comp.flux.fixed and ( 

293 (not is_fractional) or (idx_comp_group == 0) 

294 ) 

295 n_params_flux_expect = int(has_params_flux) 

296 if len(params_flux) != n_params_flux_expect: 

297 raise RuntimeError( 

298 f"{params_flux=} has len={len(params_flux)} but expected {n_params_flux_expect}" 

299 ) 

300 if has_params_flux: 

301 parameters[f"{prefix_comp}{label_flux}"] = params_flux[0] 

302 # TODO: return this to component.integralmodel 

303 # when binding for g2f.FractionalIntegralModel is fixed 

304 params_fluxfrac = [ 

305 param 

306 for param in get_params_uniq(component, fixed=False, linear=False) 

307 if isinstance(param, g2f.ProperFractionParameterD) 

308 ] 

309 if is_fractional: 

310 # The last flux fraction must be fixed at 1.0, since it 

311 # by definition contains all of the remaining flux 

312 if is_last: 

313 if (config_comp.fluxfrac.value_initial != 1.0) or (not config_comp.fluxfrac.fixed): 

314 raise ValueError( 

315 f"{config_comp=} {is_last=} and must be fixed with value_initial==1.0" 

316 ) 

317 else: 

318 if not config_comp.fluxfrac.fixed: 

319 parameters[f"{prefix_comp}{label_fluxfrac}"] = params_fluxfrac[-1] 

320 n_params_flux_frac += 1 

321 if len(params_fluxfrac) != n_params_flux_frac: 

322 raise RuntimeError( 

323 f"{config_comp=} has {params_fluxfrac=} but expected {n_params_flux_frac=}" 

324 ) 

325 else: 

326 if len(params_fluxfrac) > 0: 

327 raise RuntimeError(f"{config_group=} has {params_fluxfrac=} but {is_fractional=}") 

328 

329 return parameters 

330 

331 @cached_property 

332 def psf_model(self) -> g2f.PsfModel: 

333 """Return the PSF model.""" 

334 psf_model = self.config.make_psf_model() 

335 return psf_model 

336 

337 @cached_property 

338 def psf_model_gaussians(self) -> g2.Gaussians: 

339 """Return the Gaussians comprising the PSF model.""" 

340 gaussians = self.psf_model.gaussians() 

341 return gaussians 

342 

343 

344class CatalogExposurePsfABC(CatalogExposureABC): 

345 """A CatalogExposure for PSF fitting.""" 

346 

347 @abstractmethod 

348 def get_psf_image( 

349 self, 

350 source: SourceRow, 

351 ) -> np.ndarray: 

352 """Get a PSF image for a specific source. 

353 

354 Parameters 

355 ---------- 

356 source 

357 The source row/dict. 

358 

359 Returns 

360 ------- 

361 psf 

362 The image of the PSF. 

363 

364 Notes 

365 ----- 

366 The PSF image should be normalized, and centered in a 2D array of odd 

367 dimensions on both sides. 

368 """ 

369 

370 

371class CatalogPsfFitter: 

372 """Fit a Gaussian mixture model to a pixelated PSF image. 

373 

374 Parameters 

375 ---------- 

376 modeller : `multiprofit.Modeller` 

377 A Modeller instance to use for fitting. 

378 errors_expected : dict[Type[Exception], str] 

379 A dictionary keyed by an Exception type, with a string value of the 

380 flag column key to assign if this Exception is raised. 

381 

382 Notes 

383 ----- 

384 Any exceptions raised and not in errors_expected will be logged in a 

385 generic unknown_flag failure column. 

386 """ 

387 

388 def __init__( 

389 self, 

390 modeller: Modeller | None = None, 

391 errors_expected: dict[Type[Exception], str] | None = None, 

392 ): 

393 if modeller is None: 

394 modeller = Modeller() 

395 if errors_expected is None: 

396 errors_expected = {} 

397 self.errors_expected = errors_expected 

398 self.modeller = modeller 

399 

400 @staticmethod 

401 def _get_data_default(img_psf: np.ndarray, gain: float = 1e5) -> g2f.DataD: 

402 """Return a default-initialized data for a given PSF image. 

403 

404 Parameters 

405 ---------- 

406 img_psf 

407 A normalized image of the PSF, ideally with noise. 

408 gain 

409 The multiplicative factor needed to change the image to counts. 

410 

411 Returns 

412 ------- 

413 data 

414 A DataD instance than can be used to fit a PSF model. 

415 """ 

416 # TODO: Try to improve these ad-hoc settings after DM-49008 merges 

417 # e.g. estimate PSF model noise from S/N of PSF stars? 

418 # If more than 1% of pixels are negative, try to estimate noise 

419 if np.sum(img_psf < 0) / img_psf.size > 0.01: 

420 background = np.std(img_psf[img_psf < 2 * np.abs(np.min(img_psf))]) 

421 # otherwise, try to estimate noise from the lowest-values pixels 

422 # There might not actually be much noise; in that case, the 

423 # background value is not going to matter much anyway 

424 else: 

425 background = np.max((1e-10, np.std(img_psf[img_psf <= np.percentile(img_psf, 5.0)]))) 

426 min_psf = np.min(img_psf) 

427 # ensure there are no negative values before sqrt 

428 if not (background > -min_psf): 

429 background = -1.1 * min_psf 

430 img_sig_inv = np.sqrt(gain / (img_psf + background)) 

431 return g2f.DataD( 

432 [ 

433 g2f.ObservationD( 

434 channel=g2f.Channel.NONE, 

435 image=g2.ImageD(img_psf), 

436 sigma_inv=g2.ImageD(img_sig_inv), 

437 mask_inv=g2.ImageB(np.ones_like(img_psf)), 

438 ) 

439 ] 

440 ) 

441 

442 def _get_data(self, img_psf: np.ndarray, gain: float = 1e5) -> g2f.DataD: 

443 """Build a Model-able gauss2d.fit.Data from a normalized PSF image. 

444 

445 Parameters 

446 ---------- 

447 img_psf 

448 A normalized PSF image array. 

449 gain 

450 The number of counts in the image, used as a multiplicative 

451 factor for the inverse variance. 

452 

453 Returns 

454 ------- 

455 data 

456 A Data object that can be passed to a Model(ler). 

457 """ 

458 return self._get_data_default(img_psf=img_psf, gain=gain) 

459 

460 @staticmethod 

461 def _get_logger() -> logging.Logger: 

462 """Return a suitably-named and configured logger.""" 

463 logger = logging.getLogger(__name__) 

464 return logger 

465 

466 @abstractmethod 

467 def check_source(self, source: SourceRow, config: CatalogPsfFitterConfig) -> None: 

468 """Check whether a source can have its PSF model fit. 

469 

470 Parameters 

471 ---------- 

472 source 

473 The source row to check. 

474 config 

475 The fitter config. 

476 

477 Notes 

478 ----- 

479 Derived classes may use the source row as they deem fit. For example, 

480 if the source has poor quality flags, a fitter may choose not to fit 

481 the PSF model if it will not end up being used anyway. 

482 """ 

483 

484 def fit( 

485 self, 

486 catexp: CatalogExposurePsfABC, 

487 config_data: CatalogPsfFitterConfigData | None = None, 

488 logger: logging.Logger | None = None, 

489 **kwargs: Any, 

490 ) -> astropy.table.Table: 

491 """Fit PSF models for a catalog with MultiProFit. 

492 

493 Each source has its PSF fit with a configureable Gaussian mixture PSF 

494 model, given a pixellated PSF image from the CatalogExposure. 

495 

496 Parameters 

497 ---------- 

498 catexp 

499 An exposure to fit a model PSF at the position of all 

500 sources in the corresponding catalog. 

501 config_data 

502 Configuration settings for fitting and output. 

503 logger 

504 The logger. Defaults to calling `_getlogger`. 

505 **kwargs 

506 Additional keyword arguments to pass to self.modeller. 

507 

508 Returns 

509 ------- 

510 catalog 

511 A table with fit parameters for the PSF model at the location 

512 of each source. 

513 """ 

514 if config_data is None: 

515 config_data = CatalogPsfFitterConfigData(config=CatalogPsfFitterConfig()) 

516 if logger is None: 

517 logger = CatalogPsfFitter._get_logger() 

518 config = config_data.config 

519 if config.compute_errors != "NONE": 

520 raise ValueError("CatalogPsfFitter doesn't support computing errors") 

521 

522 errors_expected = set(self.errors_expected.values()) 

523 n_errors_expected = len(errors_expected) 

524 if n_errors_expected != len(self.errors_expected): 

525 raise ValueError(f"{self.errors_expected=} has duplicate values; they must be unique") 

526 if n_errors_expected != len(config.flag_errors): 

527 raise ValueError(f"len({self.errors_expected=}) != len({config.flag_errors=})") 

528 

529 priors = [] 

530 sigmas = [ 

531 np.linalg.norm((comp.size_x.value_initial, comp.size_y.value_initial)) 

532 for comp in config_data.component_configs.values() 

533 ] 

534 

535 psf_model = config_data.psf_model 

536 model_source = g2f.Source(psf_model.components) 

537 

538 for idx, (comp, config_comp) in enumerate( 

539 zip(psf_model.components, config_data.component_configs.values()) 

540 ): 

541 prior = config_comp.make_shape_prior(comp.ellipse) 

542 if prior: 

543 if prior_size := prior.prior_size: 

544 prior_size.mean = sigmas[idx] 

545 if prior_axrat := prior.prior_axrat: 

546 prior_axrat.mean = config.prior_axrat_mean 

547 priors.append(prior) 

548 

549 params = config_data.parameters 

550 flux_total = tuple(get_params_uniq(psf_model, nonlinear=False, channel=g2f.Channel.NONE)) 

551 if len(flux_total) != 1: 

552 raise RuntimeError(f"len({flux_total=}) != 1; PSF model is badly-formed") 

553 flux_total = flux_total[0] 

554 gaussians_linear = None 

555 if config.fit_linear_init: 

556 # The total flux must be freed first or else LinearGaussians.make 

557 # will fail to find the required number of free linear params 

558 flux_total.fixed = False 

559 gaussians_linear = LinearGaussians.make(model_source, is_psf=True) 

560 flux_total.fixed = True 

561 

562 # TODO: Remove isinstance when channel filtering is fixed: DM-45665 

563 fluxfracs = tuple( 

564 param 

565 for param in get_params_uniq(model_source, linear=False, channel=g2f.Channel.NONE, fixed=False) 

566 if isinstance(param, g2f.ProperFractionParameterD) 

567 ) 

568 # We're fitting the PSF, so there's nothing to convolve with 

569 model_psf = make_psf_model_null() 

570 

571 catalog = catexp.get_catalog() 

572 n_rows = len(catalog) 

573 range_idx = range(n_rows) 

574 

575 results, columns = config.make_catalog(n_rows) 

576 prefix = config.prefix_column 

577 columns_param = {f"{prefix}{key}": param for key, param in params.items()} 

578 

579 # dummy size for first iteration 

580 size, size_new = 0, 0 

581 fitInputs = FitInputsDummy() 

582 time_init_all = time.process_time() 

583 logger_periodic = PeriodicLogger(logger) 

584 n_skipfail = 0 

585 

586 for idx in range_idx: 

587 time_init = time.process_time() 

588 row = results[idx] 

589 source = catalog[idx] 

590 id_source = source[config.column_id] 

591 row[config.column_id] = id_source 

592 time_final = time_init 

593 

594 try: 

595 self.check_source(source, config=config) 

596 img_psf = catexp.get_psf_image(source) 

597 data = self._get_data(img_psf) 

598 model = g2f.ModelD(data=data, psfmodels=[model_psf], sources=[model_source], priors=priors) 

599 self.initialize_model(model=model, config_data=config_data) 

600 

601 # Caches the jacobian residual if the kernel size is unchanged 

602 if img_psf.size != size: 

603 fitInputs = None 

604 size = int(img_psf.size) 

605 # Some algorithms might not even use fitInputs 

606 elif fitInputs is not None: 

607 fitInputs = fitInputs if not fitInputs.validate_for_model(model) else None 

608 

609 if config.fit_linear_init: 

610 result = self.modeller.fit_gaussians_linear(gaussians_linear, data[0]) 

611 result = list(result.values())[0] 

612 # Re-normalize fluxes (hopefully close already) 

613 result = np.clip( 

614 result * np.array([x[1].value for x in gaussians_linear.gaussians_free]), 

615 1e-2, 

616 0.99, 

617 ) 

618 result /= np.sum(result) 

619 for idx_param, param in enumerate(fluxfracs): 

620 param.value = result[idx_param] 

621 # fluxfracs are the fraction of the remaining flux 

622 # this renormalizes to the remaining parameters 

623 result /= np.sum(result[idx_param + 1 :]) 

624 

625 result_full = self.modeller.fit_model(model, fitinputs=fitInputs, **kwargs) 

626 fitInputs = result_full.inputs 

627 results[f"{prefix}n_iter"][idx] = result_full.n_eval_func 

628 results[f"{prefix}time_eval"][idx] = result_full.time_eval 

629 results[f"{prefix}time_fit"][idx] = result_full.time_run 

630 results[f"{prefix}chisq_reduced"][idx] = result_full.chisq_best / size 

631 if config.config_fit.eval_residual: 

632 results[f"{prefix}n_eval_jac"][idx] = result_full.n_eval_jac 

633 

634 for (key, param), value in zip(columns_param.items(), result_full.params_best): 

635 param.value_transformed = value 

636 results[key][idx] = param.value 

637 

638 time_final = time.process_time() 

639 results[f"{prefix}time_full"][idx] = time_final - time_init 

640 except Exception as e: 

641 n_skipfail += 1 

642 size = 0 if fitInputs is None else size_new 

643 column = self.errors_expected.get(e.__class__, "") 

644 if column: 

645 row[f"{prefix}{column}"] = True 

646 logger.debug( 

647 "id_source=%i (idx=%i/%i) PSF fit failed with known exception: %s", 

648 id_source, 

649 idx, 

650 n_rows, 

651 e, 

652 ) 

653 else: 

654 row[f"{prefix}unknown_flag"] = True 

655 logger.info( 

656 "id_source=%i (idx=%i/%i) PSF fit failed with unexpected exception: %s", 

657 id_source, 

658 idx, 

659 n_rows, 

660 e, 

661 exc_info=1, 

662 ) 

663 logger_periodic.log( 

664 "Fit idx=%i/%i PSFs (%i skipped/failed) in %.2f", 

665 idx, 

666 n_rows, 

667 n_skipfail, 

668 time_final - time_init_all, 

669 ) 

670 

671 n_unknown = np.sum(row[f"{prefix}unknown_flag"]) 

672 if n_unknown > 0: 

673 logger.warning("%i/%i PSF fits failed with unexpected exceptions", n_unknown, n_rows) 

674 

675 return results 

676 

677 def initialize_model( 

678 self, 

679 model: g2f.ModelD, 

680 config_data: CatalogPsfFitterConfigData, 

681 limits_x: g2f.LimitsD | None = None, 

682 limits_y: g2f.LimitsD | None = None, 

683 ) -> None: 

684 """Initialize a Model for a single source row. 

685 

686 Parameters 

687 ---------- 

688 model 

689 The model object to initialize. 

690 config_data 

691 The fitter config with cached data. 

692 limits_x 

693 Hard limits for the source's x centroid. Default (0, n_columns). 

694 limits_y 

695 Hard limits for the source's y centroid. Default (0, n_rows). 

696 """ 

697 n_rows, n_cols = model.data[0].image.data.shape 

698 cen_x, cen_y = n_cols / 2.0, n_rows / 2.0 

699 centroids = set() 

700 if limits_x is None: 

701 limits_x = g2f.LimitsD(0, n_cols) 

702 if limits_y is None: 

703 limits_y = g2f.LimitsD(0, n_rows) 

704 

705 for component, config_comp in zip( 

706 config_data.components.values(), config_data.component_configs.values() 

707 ): 

708 centroid = component.centroid 

709 if centroid not in centroids: 

710 centroid.x_param.value = cen_x 

711 centroid.x_param.limits = limits_x 

712 centroid.y_param.value = cen_y 

713 centroid.y_param.limits = limits_y 

714 centroids.add(centroid) 

715 ellipse = component.ellipse 

716 ellipse.size_x_param.limits = limits_x 

717 ellipse.size_x = config_comp.size_x.value_initial 

718 ellipse.size_y_param.limits = limits_y 

719 ellipse.size_y = config_comp.size_y.value_initial 

720 ellipse.rho = config_comp.rho.value_initial