Coverage for python/lsst/multiprofit/fitting/fit_source.py: 10%

517 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-30 01:46 -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 

22from abc import ABC, abstractmethod 

23from functools import cached_property 

24import logging 

25import time 

26from typing import Any, ClassVar, Iterable, Mapping, Self, Sequence, Type 

27 

28import astropy 

29from astropy.table import Table 

30import astropy.units as u 

31import lsst.gauss2d.fit as g2f 

32import lsst.pex.config as pexConfig 

33from lsst.utils.logging import PeriodicLogger 

34import numpy as np 

35import pydantic 

36 

37from ..componentconfig import Fluxes, GaussianComponentConfig 

38from ..errors import NoDataError, RaDecConversionNotImplementedError 

39from ..modelconfig import ModelConfig 

40from ..modeller import FitInputsDummy, Modeller 

41from ..sourceconfig import ComponentGroupConfig, SourceConfig 

42from ..utils import frozen_arbitrary_allowed_config, get_params_uniq 

43from .fit_catalog import CatalogExposureABC, CatalogFitterConfig, ColumnInfo 

44 

45__all__ = [ 

46 "CatalogExposureSourcesABC", 

47 "CatalogSourceFitterConfig", 

48 "CatalogSourceFitterConfigData", 

49 "CatalogSourceFitterABC", 

50] 

51 

52 

53class CatalogExposureSourcesABC(CatalogExposureABC): 

54 """Interface for a CatalogExposure for source modelling.""" 

55 

56 @property 

57 def band(self) -> str: 

58 """Return the name of the exposure's passband (e.g. 'r').""" 

59 return self.channel.name 

60 

61 # Note: not named band because that's usually a string 

62 @property 

63 @abstractmethod 

64 def channel(self) -> g2f.Channel: 

65 """Return the exposure's associated channel object.""" 

66 

67 @abstractmethod 

68 def get_psf_model(self, params: Mapping[str, Any]) -> g2f.PsfModel | None: 

69 """Get the PSF model for a given source row. 

70 

71 Parameters 

72 ---------- 

73 params : Mapping[str, Any] 

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

75 centroid of a single source. 

76 

77 Returns 

78 ------- 

79 psf_model : `lsst.gauss2d.fit.PsfModel` 

80 A PsfModel object initialized with the best-fit parameters, or None 

81 if PSF rebuilding failed for an expected reason (i.e. the input PSF 

82 fit table has a flag set). 

83 """ 

84 

85 @abstractmethod 

86 def get_source_observation(self, source: Mapping[str, Any], **kwargs: Any) -> g2f.ObservationD | None: 

87 """Get the Observation for a given source row. 

88 

89 Parameters 

90 ---------- 

91 source : Mapping[str, Any] 

92 A mapping with any values needed to retrieve an observation for a 

93 single source. 

94 **kwargs 

95 Additional keyword arguments not used during fitting. 

96 

97 Returns 

98 ------- 

99 observation : `lsst.gauss2d.fit.Observation` 

100 An Observation object with suitable data for fitting parametric 

101 models of the source, or None if the observation cannot be fit. 

102 """ 

103 

104 

105class CatalogSourceFitterConfig(CatalogFitterConfig): 

106 """Configuration for the MultiProFit profile fitter.""" 

107 

108 centroid_pixel_offset = pexConfig.Field[float]( 

109 doc="Number to add to MultiProFit centroids (bottom-left corner is 0,0) to convert to catalog" 

110 " coordinates (e.g. set to -0.5 if the bottom-left corner is -0.5, -0.5)", 

111 default=0, 

112 ) 

113 compute_radec_covariance = pexConfig.Field[bool]( 

114 doc="Whether to compute the RA/dec covariance. Ignore if convert_cen_xy_to_radec is False.", 

115 default=False, 

116 ) 

117 config_model = pexConfig.ConfigField[ModelConfig](doc="Source model configuration") 

118 convert_cen_xy_to_radec = pexConfig.Field[bool]( 

119 doc="Convert pixel x/y centroid params to RA/dec", 

120 default=True, 

121 ) 

122 defer_radec_conversion = pexConfig.Field[bool]( 

123 doc="Whether to defer conversion of pixel x/y centroid params to RA/dec to compute_model_radec_err." 

124 " Only effective if convert_cen_xy_to_radec and compute_errors is not NONE, and requires that the" 

125 " overloaded compute_model_radec_err method sets RA/dec values itself.", 

126 default=False, 

127 ) 

128 fit_psmodel_final = pexConfig.Field[bool]( 

129 default=False, 

130 doc="Fit a point source model after optimization", 

131 ) 

132 prior_cen_x_stddev = pexConfig.Field[float]( 

133 default=0, doc="Prior std. dev. on x centroid (ignored if not >0)" 

134 ) 

135 prior_cen_y_stddev = pexConfig.Field[float]( 

136 default=0, doc="Prior std. dev. on y centroid (ignored if not >0)" 

137 ) 

138 unit_flux = pexConfig.Field[str](default=None, doc="Flux unit", optional=True) 

139 

140 def make_model_data( 

141 self, 

142 idx_row: int, 

143 catexps: list[CatalogExposureSourcesABC], 

144 ) -> tuple[g2f.DataD, list[g2f.PsfModel]]: 

145 """Make data and psf_models for a catalog row. 

146 

147 Parameters 

148 ---------- 

149 idx_row 

150 The index of the row in each catalog. 

151 catexps 

152 Catalog-exposure pairs to initialize observations from. 

153 

154 Returns 

155 ------- 

156 data 

157 The resulting data object. 

158 psf_models 

159 A list of psf_models, one per catexp. 

160 

161 Notes 

162 ----- 

163 Only observations with good data and valid PSF models will be 

164 returned; bad data will be excluded from the return values. 

165 """ 

166 observations = [] 

167 psf_models = [] 

168 

169 for catexp in catexps: 

170 catalog = catexp.get_catalog() 

171 # This indicates that there's no corresponding exposure 

172 # (the catexp interface expects a tabular type for catalog but 

173 # no interface for an exposure has been defined, yet) 

174 if len(catalog) == 0: 

175 continue 

176 source = catalog[idx_row] 

177 observation = catexp.get_source_observation(source) 

178 # If the observation or PSF model is bad enough that it cannot be 

179 # fit, do not add it to the data. 

180 if observation is not None: 

181 psf_model = catexp.get_psf_model(source) 

182 if psf_model is not None: 

183 observations.append(observation) 

184 # PSF model parameters cannot be fit along with sources 

185 for param in get_params_uniq(psf_model): 

186 param.fixed = True 

187 psf_models.append(psf_model) 

188 

189 data = g2f.DataD(observations) 

190 return data, psf_models 

191 

192 def make_point_sources( 

193 self, 

194 channels: Iterable[g2f.Channel], 

195 sources: list[g2f.Source], 

196 ) -> tuple[list[g2f.Source], list[g2f.Prior]]: 

197 """Make initialized point sources given channels. 

198 

199 Parameters 

200 ---------- 

201 channels 

202 The channels to initialize fluxes for. 

203 sources 

204 List of sources. 

205 

206 Returns 

207 ------- 

208 sources 

209 The list of initialized sources. 

210 priors 

211 The list of priors. 

212 

213 Notes 

214 ----- 

215 The prior list is always empty, but is returned to keep this function 

216 consistent with make_sources. 

217 """ 

218 point_sources = [] 

219 fluxes = [[{channel: 1.0 for channel in channels}]] 

220 

221 for (name_src, config_src), source in zip(self.config_model.sources.items(), sources): 

222 centroids = next(iter(config_src.component_groups.values())).centroids 

223 config_src_psf = SourceConfig( 

224 component_groups={ 

225 "": ComponentGroupConfig( 

226 centroids=centroids, 

227 components_gauss={"": GaussianComponentConfig()}, 

228 ) 

229 } 

230 ) 

231 source, _ = config_src_psf.make_source(fluxes) 

232 point_sources.append(source) 

233 

234 return point_sources, [] 

235 

236 def make_sources( 

237 self, 

238 channels: Iterable[g2f.Channel], 

239 source_fluxes: list[list[list[Fluxes]]] | None = None, 

240 ) -> tuple[list[g2f.Source], list[g2f.Prior]]: 

241 """Make initialized sources given channels using `self.config_model`. 

242 

243 Parameters 

244 ---------- 

245 channels 

246 The channels to initialize fluxes for. 

247 source_fluxes 

248 A list of fluxes by channel for each component group in each 

249 source. The default is to initialize using 

250 `ComponentGroupConfig.get_fluxes_default`. 

251 

252 Returns 

253 ------- 

254 sources 

255 The list of initialized sources. 

256 priors 

257 The list of priors. 

258 """ 

259 n_sources = len(self.config_model.sources) 

260 if source_fluxes is None: 

261 source_fluxes = [None] * n_sources 

262 for idx, (config_source, component_group_fluxes) in enumerate( 

263 zip( 

264 self.config_model.sources.values(), 

265 source_fluxes, 

266 ) 

267 ): 

268 component_group_fluxes = [ 

269 component_group.get_fluxes_default( 

270 channels=channels, 

271 component_configs=component_group.get_component_configs(), 

272 is_fractional=component_group.is_fractional, 

273 ) 

274 for component_group in config_source.component_groups.values() 

275 ] 

276 source_fluxes[idx] = component_group_fluxes 

277 else: 

278 if len(source_fluxes) != n_sources: 

279 raise ValueError(f"{len(source_fluxes)=} != {len(self.config_model.sources)=}") 

280 

281 sources, priors = self.config_model.make_sources( 

282 component_group_fluxes_srcs=source_fluxes, 

283 ) 

284 

285 has_prior_x = self.prior_cen_x_stddev > 0 and np.isfinite(self.prior_cen_x_stddev) 

286 has_prior_y = self.prior_cen_y_stddev > 0 and np.isfinite(self.prior_cen_y_stddev) 

287 if has_prior_x or has_prior_y: 

288 for source in sources: 

289 for param in get_params_uniq(source, fixed=False): 

290 if has_prior_x and isinstance(param, g2f.CentroidXParameterD): 

291 priors.append(g2f.GaussianPrior(param.x_param_ptr, 0, self.prior_cen_x_stddev)) 

292 elif has_prior_y and isinstance(param, g2f.CentroidYParameterD): 

293 priors.append(g2f.GaussianPrior(param.y_param_ptr, 0, self.prior_cen_y_stddev)) 

294 

295 return sources, priors 

296 

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

298 columns = [] 

299 if self.config_fit.eval_residual: 

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

301 if self.fit_linear_final: 

302 columns.append(ColumnInfo(key="delta_lnL_fit_linear", dtype="f8")) 

303 if self.fit_psmodel_final: 

304 columns.append(ColumnInfo(key="delta_lnL_fit_ps", dtype="f8")) 

305 return columns 

306 

307 def schema( 

308 self, 

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

310 ) -> list[ColumnInfo]: 

311 if bands is None or not (len(bands) > 0): 

312 raise ValueError("CatalogSourceFitter must provide at least one band") 

313 schema = super().schema(bands) 

314 

315 parameters = CatalogSourceFitterConfigData( 

316 config=self, 

317 channels=tuple((g2f.Channel.get(band) for band in bands)), 

318 ).parameters 

319 unit_size = u.Unit("pix") 

320 units = { 

321 g2f.IntegralParameterD: self.unit_flux, 

322 g2f.ReffXParameterD: unit_size, 

323 g2f.ReffYParameterD: unit_size, 

324 g2f.SizeXParameterD: unit_size, 

325 g2f.SizeYParameterD: unit_size, 

326 } 

327 idx_start = len(schema) 

328 schema.extend( 

329 [ 

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

331 for key, param in parameters.items() 

332 ] 

333 ) 

334 # Keep track of covariance key by declination parameter indexs 

335 # If we want to add RA/dec covariance, it'll need to come after decErr 

336 keys_cov = {} 

337 compute_errors = self.compute_errors != "NONE" 

338 if self.convert_cen_xy_to_radec: 

339 label_cen = self.get_key_cen() 

340 cen_underscored = label_cen.startswith("_") 

341 suffix_x, suffix_y, suffix_ra, suffix_dec = ( 

342 f"{label_cen}{suffix}" 

343 for suffix in ( 

344 self.get_suffix_x(), 

345 self.get_suffix_y(), 

346 self.get_suffix_ra(), 

347 self.get_suffix_dec(), 

348 ) 

349 ) 

350 suffix_ra = f"{label_cen}{self.get_suffix_ra()}" 

351 suffix_dec = f"{label_cen}{self.get_suffix_dec()}" 

352 for key, param in parameters.items(): 

353 # TODO: Update if allowing x, y <-> dec, RA mappings 

354 # ... or arbitrary rotations 

355 is_y = isinstance(param, g2f.CentroidYParameterD) 

356 suffix_radec, suffix_xy = ( 

357 (suffix_ra, suffix_x) 

358 if isinstance(param, g2f.CentroidXParameterD) 

359 else ((suffix_dec, suffix_y) if is_y else (None, None)) 

360 ) 

361 if suffix_radec is not None: 

362 # Add whatever the corresponding prefix is, and also 

363 # remove any leading underscore if there's no prefix 

364 prefix, suffix = ( 

365 ("", suffix_radec[1:]) 

366 if (cen_underscored and (key == suffix_xy[1:])) 

367 else (key.split(suffix_xy)[0], suffix_radec) 

368 ) 

369 schema.append(ColumnInfo(key=f"{prefix}{suffix}", dtype="f8", unit=u.deg)) 

370 if compute_errors and is_y: 

371 suffix_radec = f"{label_cen}{self.get_suffix_ra_dec_cov()}" 

372 prefix, suffix = ( 

373 ("", suffix_radec[1:]) 

374 if (cen_underscored and (key == suffix_xy[1:])) 

375 else (key.split(suffix_xy)[0], suffix_radec) 

376 ) 

377 keys_cov[len(schema) - 1] = f"{prefix}{suffix}" 

378 if compute_errors: 

379 suffix = self.suffix_error 

380 idx_end = len(schema) 

381 for idx in range(idx_start, idx_end): 

382 column = schema[idx] 

383 schema.append(ColumnInfo(key=f"{column.key}{suffix}", dtype=column.dtype, unit=column.unit)) 

384 if (key_cov := keys_cov.get(idx)) is not None: 

385 schema.append(ColumnInfo(key=key_cov, dtype="f8", unit=u.deg**2)) 

386 

387 schema.extend(self.schema_configurable()) 

388 return schema 

389 

390 

391class CatalogSourceFitterConfigData(pydantic.BaseModel): 

392 """Configuration data for a fitter that can initialize lsst.gauss2d.fit 

393 models and images thereof. 

394 

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

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

397 ensure that the model remains unchanged. 

398 """ 

399 

400 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

401 

402 channels: list[g2f.Channel] = pydantic.Field(title="The list of channels") 

403 config: CatalogSourceFitterConfig = pydantic.Field(title="A CatalogSourceFitterConfig to be frozen") 

404 

405 @pydantic.model_validator(mode="after") 

406 def validate_config(self) -> Self: 

407 self.config.validate() 

408 return self 

409 

410 @cached_property 

411 def components(self) -> tuple[g2f.Component]: 

412 sources = self.sources_priors[0] 

413 components = [] 

414 for source in sources: 

415 components.extend(source.components) 

416 return components 

417 

418 @cached_property 

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

420 config = self.config 

421 config_model = config.config_model 

422 idx_comp_first = 0 

423 has_prefix_source = config_model.has_prefix_source() 

424 n_channels = len(self.channels) 

425 parameters = {} 

426 

427 label_cen = config.get_key_cen() 

428 label_rho = config.get_key_rho() 

429 label_sersic = config.get_key_sersicindex() 

430 label_x, label_y = config.get_suffix_x(), config.get_suffix_y() 

431 

432 for name_source, config_source in config_model.sources.items(): 

433 prefix_source = f"{name_source}_" if has_prefix_source else "" 

434 has_prefix_group = config_source.has_prefix_group() 

435 

436 for name_group, config_group in config_source.component_groups.items(): 

437 prefix_group = f"{prefix_source}{name_group}_" if has_prefix_group else prefix_source 

438 multicen = len(config_group.centroids) > 1 

439 configs_comp = config_group.get_component_configs().items() 

440 

441 is_multicomp = len(configs_comp) > 1 

442 

443 for idx_comp_group, (name_comp, config_comp) in enumerate(configs_comp): 

444 component = self.components[idx_comp_first + idx_comp_group] 

445 

446 key_comp = name_comp if is_multicomp else "" 

447 prefix_comp = f"{prefix_group}{key_comp}" 

448 key_size = config.get_prefixed_label( 

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

450 prefix_comp, 

451 ) 

452 key_rho = config.get_prefixed_label(label_rho, prefix_comp) 

453 

454 if multicen or (idx_comp_group == 0): 

455 prefix_cen = prefix_comp if multicen else prefix_group 

456 # Avoid double-underscoring if there's nothing to 

457 # prefix or an existing prefix 

458 key_cen = config.get_prefixed_label(label_cen, prefix_cen) 

459 parameters[f"{key_cen}{label_x}"] = component.centroid.x_param 

460 parameters[f"{key_cen}{label_y}"] = component.centroid.y_param 

461 if not config_comp.size_x.fixed: 

462 parameters[f"{key_size}{label_x}"] = component.ellipse.size_x_param 

463 if not config_comp.size_y.fixed: 

464 parameters[f"{key_size}{label_y}"] = component.ellipse.size_y_param 

465 if not config_comp.rho.fixed: 

466 parameters[key_rho] = component.ellipse.rho_param 

467 if not config_comp.flux.fixed: 

468 # TODO: return this to component.integralmodel 

469 # when binding for g2f.FractionalIntegralModel is fixed 

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

471 if len(params_flux) != n_channels: 

472 raise ValueError(f"{params_flux=} len={len(params_flux)} != {n_channels=}") 

473 for channel, param_flux in zip(self.channels, params_flux): 

474 key_flux = config.get_key_flux(label=prefix_comp, band=channel.name) 

475 parameters[key_flux] = param_flux 

476 if hasattr(config_comp, "sersic_index") and not config_comp.sersic_index.fixed: 

477 parameters[config.get_prefixed_label(label_sersic, prefix_comp)] = ( 

478 component.sersicindex_param 

479 ) 

480 

481 return parameters 

482 

483 @cached_property 

484 def sources_priors(self) -> tuple[tuple[g2f.Source], tuple[g2f.Prior]]: 

485 sources, priors = self.config.make_sources(channels=self.channels) 

486 return tuple(sources), tuple(priors) 

487 

488 

489class CatalogSourceFitterABC(ABC, pydantic.BaseModel): 

490 """Fit a Gaussian mixture source model to an image with a PSF model. 

491 

492 Notes 

493 ----- 

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

495 generic unknown_flag failure column. 

496 """ 

497 

498 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

499 

500 errors_expected: dict[Type[Exception], str] = pydantic.Field( 

501 default_factory=dict, 

502 title="A dictionary of Exceptions with the name of the flag column key to fill if raised.", 

503 ) 

504 modeller: Modeller = pydantic.Field( 

505 default_factory=Modeller, 

506 title="A Modeller instance to use for fitting.", 

507 ) 

508 

509 def _get_columns_params_radec( 

510 self, 

511 params_radec: dict[str, tuple[g2f.CentroidXParameterD, g2f.CentroidYParameterD]], 

512 compute_errors: bool, 

513 config: CatalogSourceFitterConfig, 

514 ) -> tuple[list[tuple[str, str, str, str]], list[tuple[str, str, str, str, str, str]]]: 

515 """Get a list of the columns needed for conversion of x/y centroid 

516 parameters into ra/dec. 

517 

518 Parameters 

519 ---------- 

520 params_radec 

521 Dict of tuple of x, y parameter objects by name. 

522 compute_errors 

523 Whether errors will be computed. 

524 config 

525 The configuration with column formatting parameters. 

526 

527 Returns 

528 ------- 

529 columns_params_radec 

530 Column names for RA, dec, x, and y. 

531 columns_params_radec_err 

532 Column names for RA_err, dec_err, x, y, x_err, y_err. 

533 """ 

534 columns_params_radec = [] 

535 columns_params_radec_err = [] 

536 suffix_err = config.suffix_error 

537 key_cen = config.get_key_cen() 

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

539 suffix_ra, suffix_dec = config.get_suffix_ra(), config.get_suffix_dec() 

540 

541 for key_base, (param_cen_x, param_cen_y) in params_radec.items(): 

542 # This removes redundant underscores 

543 key_base_cen = config.get_prefixed_label(key_cen, key_base) 

544 

545 if param_cen_y is None: 

546 raise RuntimeError( 

547 f"Fitter failed to find corresponding cen_y param for {key_base=}; is it fixed?" 

548 ) 

549 column_ra = f"{key_base_cen}{suffix_ra}" 

550 column_dec = f"{key_base_cen}{suffix_dec}" 

551 

552 columns_params_radec.append( 

553 ( 

554 column_ra, 

555 column_dec, 

556 f"{key_base_cen}{suffix_x}", 

557 f"{key_base_cen}{suffix_y}", 

558 ) 

559 ) 

560 if compute_errors: 

561 key_cov = ( 

562 None 

563 if not config.compute_radec_covariance 

564 else (f"{key_base_cen}{config.get_suffix_ra_dec_cov()}") 

565 ) 

566 columns_params_radec_err.append( 

567 ( 

568 f"{key_base_cen}{suffix_ra}{suffix_err}", 

569 f"{key_base_cen}{suffix_dec}{suffix_err}", 

570 f"{key_base_cen}{suffix_x}", 

571 f"{key_base_cen}{suffix_y}", 

572 f"{key_base_cen}{suffix_x}{suffix_err}", 

573 f"{key_base_cen}{suffix_y}{suffix_err}", 

574 key_cov, 

575 column_ra, 

576 column_dec, 

577 ) 

578 ) 

579 return columns_params_radec, columns_params_radec_err 

580 

581 @staticmethod 

582 def _get_logger() -> logging.Logger: 

583 logger = logging.getLogger(__name__) 

584 

585 return logger 

586 

587 def _validate_errors_expected(self, config: CatalogSourceFitterConfig) -> None: 

588 """Check that self.errors_expected is set correctly. 

589 

590 Parameters 

591 ---------- 

592 config 

593 The fitting configuration. 

594 

595 Raises 

596 ------ 

597 ValueError 

598 Raised if the configuration is invalid. 

599 """ 

600 if len(self.errors_expected) != len(config.flag_errors): 

601 raise ValueError(f"{self.errors_expected=} keys not same len as {config.flag_errors=}") 

602 errors_bad = {} 

603 errors_recast = {} 

604 for error_name, error_type in self.errors_expected.items(): 

605 if error_type in errors_recast: 

606 errors_bad[error_name] = error_type 

607 else: 

608 errors_recast[error_type] = error_name 

609 if errors_bad: 

610 raise ValueError(f"{self.errors_expected=} keys contain duplicates from {config.flag_errors=}") 

611 

612 def compute_model_radec_err( 

613 self, 

614 source_multi: Mapping[str, Any], 

615 results, 

616 columns_params_radec_err, 

617 idx: int, 

618 set_radec: bool = False, 

619 ) -> None: 

620 """Compute right ascension and declination errors for a source. 

621 

622 This default implementation is naive, assuming only that 

623 get_model_radec is implemented, and should be overridden. 

624 

625 Parameters 

626 ---------- 

627 source_multi 

628 A mapping with fields expected to be populated in the 

629 corresponding multiband source catalog. 

630 results 

631 The output catalog to read/write from/to. 

632 columns_params_radec_err 

633 A list of tuples containing six keys for: 

634 ra, dec: RA/Dec inputs. 

635 ra_err, dec_err: RA/Dec error outputs. 

636 cen_x, cen_y: Pixel x/y centroid inputs. 

637 cen_x_err, cen_y_err: Pixel x/y centroid error inputs. 

638 idx 

639 The integer index of this source in the results catalog. 

640 set_radec 

641 Whether this method should set RA, dec values instead of reading 

642 them (should be True if defer_radec_conversion is True). 

643 """ 

644 for ( 

645 key_ra_err, 

646 key_dec_err, 

647 key_cen_x, 

648 key_cen_y, 

649 key_cen_x_err, 

650 key_cen_y_err, 

651 key_cen_ra_dec_cov, 

652 key_ra, 

653 key_dec, 

654 ) in columns_params_radec_err: 

655 cen_x, cen_y = results[key_cen_x][idx], results[key_cen_y][idx] 

656 # TODO: improve this in DM-45682 

657 # For one, it won't work right at limits: 

658 # RA=359.99... or dec=+89.99... 

659 # Could also consider dividing by sqrt(2) 

660 # ...but that factor would multiply out later 

661 ra_err, dec_err = self.get_model_radec( 

662 source_multi, 

663 cen_x + results[key_cen_x_err][idx], 

664 cen_y + results[key_cen_y_err][idx], 

665 ) 

666 ra, dec = results[key_ra][idx], results[key_dec][idx] 

667 results[key_ra_err][idx], results[key_dec_err][idx] = abs(ra_err - ra), abs(dec_err - dec) 

668 

669 def copy_centroid_errors( 

670 self, 

671 columns_cenx_err_copy: tuple[str], 

672 columns_ceny_err_copy: tuple[str], 

673 results: Table, 

674 catalog_multi: Sequence, 

675 catexps: list[CatalogExposureSourcesABC], 

676 config_data: CatalogSourceFitterConfigData, 

677 ) -> None: 

678 """Copy centroid errors from an input catalog. 

679 

680 This method exists to support fitting models with fixed centroids 

681 derived from an input catalog. Implementers can simply copy an 

682 existing column into the results catalog or use the data as needed; 

683 however, there is no reasonable default implementation. 

684 

685 Parameters 

686 ---------- 

687 columns_cenx_err_copy 

688 X-axis result centroid columns to copy errors for. 

689 columns_ceny_err_copy 

690 Y-axis result centroid columns to copy errors for. 

691 results 

692 The table of fit results to copy errors into. 

693 catalog_multi 

694 The input multiband catalog. 

695 catexps 

696 The input data. 

697 config_data 

698 The fitter config and data. 

699 

700 Raises 

701 ------ 

702 NotImplementedError 

703 Raised if columns need to be copied but no implementation is 

704 available. 

705 """ 

706 if columns_cenx_err_copy or columns_ceny_err_copy: 

707 raise NotImplementedError( 

708 f"Fitter of {type(self)=} got {columns_cenx_err_copy=} and/or {columns_ceny_err_copy=}" 

709 f" but has not overriden copy_centroid_errors" 

710 ) 

711 

712 def fit( 

713 self, 

714 catalog_multi: Sequence, 

715 catexps: list[CatalogExposureSourcesABC], 

716 config_data: CatalogSourceFitterConfigData | None = None, 

717 logger: logging.Logger | None = None, 

718 **kwargs: Any, 

719 ) -> astropy.table.Table: 

720 """Fit PSF-convolved source models with MultiProFit. 

721 

722 Each source has a single PSF-convolved model fit, given PSF model 

723 parameters from a catalog, and a combination of initial source 

724 model parameters and a deconvolved source image from the 

725 CatalogExposureSources. 

726 

727 Parameters 

728 ---------- 

729 catalog_multi 

730 A multi-band source catalog to fit a model to. 

731 catexps 

732 A list of (source and psf) catalog-exposure pairs. 

733 config_data 

734 Configuration settings and data for fitting and output. 

735 logger 

736 The logger. Defaults to calling `_getlogger`. 

737 **kwargs 

738 Additional keyword arguments to pass to self.modeller. 

739 

740 Returns 

741 ------- 

742 catalog : `astropy.Table` 

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

744 of each source. 

745 """ 

746 if config_data is None: 

747 config_data = CatalogSourceFitterConfigData( 

748 config=CatalogSourceFitterConfig(), 

749 channels=[catexp.channel for catexp in catexps], 

750 ) 

751 if logger is None: 

752 logger = self._get_logger() 

753 

754 config = config_data.config 

755 self._validate_errors_expected(config) 

756 self.validate_fit_inputs( 

757 catalog_multi=catalog_multi, catexps=catexps, config_data=config_data, logger=logger, **kwargs 

758 ) 

759 

760 model_sources, priors = config_data.sources_priors 

761 

762 # TODO: If free Observation params are ever supported, make null Data 

763 # Because config_data knows nothing about the Observation(s) 

764 params = config_data.parameters 

765 values_init = {param: param.value for param in params.values() if param.free} 

766 prefix = config.prefix_column 

767 columns_param_fixed: dict[str, tuple[g2f.ParameterD, float]] = {} 

768 columns_param_free: dict[str, tuple[g2f.ParameterD, float]] = {} 

769 columns_param_flux: dict[str, g2f.IntegralParameterD] = {} 

770 params_cen_x: dict[str, g2f.CentroidXParameterD] = {} 

771 params_cen_y: dict[str, g2f.CentroidYParameterD] = {} 

772 columns_err = [] 

773 

774 errors_hessian: bool = config.compute_errors == "INV_HESSIAN" 

775 errors_hessian_bestfit: bool = config.compute_errors == "INV_HESSIAN_BESTFIT" 

776 compute_errors: bool = errors_hessian or errors_hessian_bestfit 

777 

778 columns_cenx_err_copy = [] 

779 columns_ceny_err_copy = [] 

780 

781 suffix_err = config.suffix_error 

782 key_cen = config.get_key_cen() 

783 cen_underscored = key_cen.startswith("_") 

784 suffix_cenx = f"{key_cen}{config.get_suffix_x()}" 

785 suffix_ceny = f"{key_cen}{config.get_suffix_y()}" 

786 

787 # Add each param to appropriate and more specific pre-computed lists 

788 for key, param in params.items(): 

789 key_full = f"{prefix}{key}" 

790 is_cenx = isinstance(param, g2f.CentroidXParameterD) 

791 is_ceny = isinstance(param, g2f.CentroidYParameterD) 

792 

793 # Add the corresponding error key to the appropriate list 

794 if compute_errors: 

795 if param.free: 

796 columns_err.append(f"{key_full}{suffix_err}") 

797 elif is_cenx: 

798 columns_cenx_err_copy.append(f"{key_full}{suffix_err}") 

799 elif is_ceny: 

800 columns_ceny_err_copy.append(f"{key_full}{suffix_err}") 

801 

802 # Add this param to the appropriate dict 

803 (columns_param_fixed if param.fixed else columns_param_free)[key_full] = ( 

804 param, 

805 config_data.config.centroid_pixel_offset if (is_cenx or is_ceny) else 0, 

806 ) 

807 if isinstance(param, g2f.IntegralParameterD): 

808 columns_param_flux[key_full] = param 

809 elif config.convert_cen_xy_to_radec: 

810 # Infer the prefix if possible, after checking for a dropped 

811 # leading underscore in case there's no prefix 

812 if is_cenx: 

813 prefix_cen, suffix_cen = ( 

814 ("", key_full) 

815 if (cen_underscored and (key_full == suffix_cenx[1:])) 

816 else key_full.split(suffix_cenx) 

817 ) 

818 params_cen_x[prefix_cen] = param 

819 elif is_ceny: 

820 prefix_cen, suffix_cen = ( 

821 ("", key_full) 

822 if (cen_underscored and (key_full == suffix_ceny[1:])) 

823 else key_full.split(suffix_ceny) 

824 ) 

825 params_cen_y[prefix_cen] = param 

826 

827 if config.convert_cen_xy_to_radec or config.fit_psmodel_final: 

828 assert params_cen_x.keys() == params_cen_y.keys() 

829 columns_params_radec, columns_params_radec_err = self._get_columns_params_radec( 

830 {k: (x, params_cen_y[k]) for k, x in params_cen_x.items()}, 

831 compute_errors, 

832 config=config, 

833 ) 

834 

835 fit_psmodel_final = False 

836 if config.fit_psmodel_final: 

837 # This should never be True until DM-46497 is merged, but models 

838 # in other/future derived classes might have multiple centroids 

839 if (len(set(params_cen_x.values())) > 1) or (len(set(params_cen_y.values())) > 1): 

840 raise ValueError( 

841 f"Got {params_cen_x=} and {params_cen_y} with > 1 unique elements, so " 

842 f"config.fit_psmodel_final may not be set to True" 

843 ) 

844 fit_psmodel_final = True 

845 

846 key_cen_x_psmodel, key_cen_y_psmodel = columns_params_radec[0][2:4] 

847 

848 channels = config_data.channels 

849 sources_psmodel, priors_psmodel = config.make_point_sources(channels, model_sources) 

850 params_psmodel = sources_psmodel[0].parameters() 

851 cenx_psmodel, ceny_psmodel = None, None 

852 fluxes_psmodel = {} 

853 idx_band = 0 

854 for param in params_psmodel: 

855 if isinstance(param, g2f.CentroidXParameterD): 

856 if cenx_psmodel is not None: 

857 raise RuntimeError("Point source model found multiple x centroids") 

858 cenx_psmodel = param 

859 elif isinstance(param, g2f.CentroidYParameterD): 

860 if ceny_psmodel is not None: 

861 raise RuntimeError("Point source model found multiple y centroids") 

862 ceny_psmodel = param 

863 elif isinstance(param, g2f.IntegralParameterD): 

864 fluxes_psmodel[channels[idx_band]] = param 

865 idx_band += 1 

866 

867 convert_cen_xy_to_radec_first = config.convert_cen_xy_to_radec and not ( 

868 config.compute_errors and config.defer_radec_conversion 

869 ) 

870 

871 # Setup the results table with correct column names 

872 n_rows = len(catalog_multi) 

873 channels = self.get_channels(catexps) 

874 results, columns = config.make_catalog(n_rows, bands=list(channels.keys())) 

875 

876 # Copy centroid error columns into results ( if needed) 

877 self.copy_centroid_errors( 

878 columns_cenx_err_copy=columns_cenx_err_copy, 

879 columns_ceny_err_copy=columns_ceny_err_copy, 

880 results=results, 

881 catalog_multi=catalog_multi, 

882 catexps=catexps, 

883 config_data=config_data, 

884 ) 

885 

886 # dummy size for first iteration 

887 size, size_new = 0, 0 

888 fitInputs = FitInputsDummy() 

889 plot = False 

890 

891 # Configure default options for calls to compute_variances 

892 # keys are for values of return_negative 

893 kwargs_err_default = { 

894 True: { 

895 "options": g2f.HessianOptions(findiff_add=1e-3, findiff_frac=1e-3), 

896 "use_diag_only": config.compute_errors_no_covar, 

897 }, 

898 False: {"options": g2f.HessianOptions(findiff_add=1e-6, findiff_frac=1e-6)}, 

899 } 

900 

901 range_idx = range(n_rows) 

902 

903 # TODO: Do this check with dummy data 

904 # It might not work with real data if the first row is bad 

905 # data, psf_models = config.make_model_data( 

906 # idx_row=range_idx[0], catexps=catexps) 

907 # model = g2f.ModelD(data=data, psfmodels=psf_models, 

908 # sources=model_sources, priors=priors) 

909 # Remember to filter out fixed centroids from params 

910 # assert list(params.values()) == get_params_uniq(model, fixed=False) 

911 

912 time_init_all = time.process_time() 

913 logger_periodic = PeriodicLogger(logger) 

914 n_skipfail = 0 

915 

916 for idx in range_idx: 

917 time_init = time.process_time() 

918 row = results[idx] 

919 source_multi = catalog_multi[idx] 

920 id_source = source_multi[config.column_id] 

921 row[config.column_id] = id_source 

922 time_final = time_init 

923 

924 try: 

925 self.validate_source(idx_row=idx, catalog_multi=catalog_multi) 

926 data, psf_models = config.make_model_data(idx_row=idx, catexps=catexps) 

927 if data.size == 0: 

928 raise NoDataError("make_model_data returned empty data") 

929 model = g2f.ModelD(data=data, psfmodels=psf_models, sources=model_sources, priors=priors) 

930 self.initialize_model( 

931 model, 

932 source_multi, 

933 catexps, 

934 config_data=config_data, 

935 values_init=values_init, 

936 ) 

937 

938 # Caches the jacobian residual if the data size is unchanged 

939 # Note: this will need to change with priors 

940 # (data should report its own size) 

941 size_new = np.sum([datum.image.size for datum in data]) 

942 if size_new != size: 

943 fitInputs = None 

944 size = size_new 

945 # Some algorithms might not even use fitInputs 

946 elif fitInputs is not None: 

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

948 

949 # TODO: Check if flux param limits and transforms are set 

950 # appropriately if config.fit_linear_init is False 

951 if config.fit_linear_init: 

952 self.modeller.fit_model_linear(model=model, ratio_min=0.01) 

953 

954 for observation in data: 

955 observation.image.data[~np.isfinite(observation.image.data)] = 0 

956 

957 result_full = self.modeller.fit_model( 

958 model, fitinputs=fitInputs, config=config.config_fit, **kwargs 

959 ) 

960 fitInputs = result_full.inputs 

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

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

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

964 if config.config_fit.eval_residual: 

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

966 

967 params_free_missing = result_full.params_free_missing or tuple() 

968 

969 # Set all params to best fit values 

970 # In case the optimizer doesn't 

971 for (key, (param, offset)), value in zip( 

972 columns_param_free.items(), 

973 result_full.params_best, 

974 ): 

975 param.value_transformed = value 

976 if param not in params_free_missing: 

977 results[key][idx] = param.value + offset 

978 

979 # Also add any offset to the fixed parameters 

980 # (usually centroids, if any) 

981 for key, (param, offset) in columns_param_fixed.items(): 

982 results[key][idx] = param.value + offset 

983 

984 # Do a final linear fit 

985 # If the nonlinear fit is good, the values won't change much 

986 if config.fit_linear_final: 

987 loglike_init, loglike_new = self.modeller.fit_model_linear( 

988 model=model, ratio_min=0.01, validate=True 

989 ) 

990 loglike_final = max(loglike_init, loglike_new) 

991 results[f"{prefix}delta_lnL_fit_linear"][idx] = np.sum(loglike_new) - np.sum(loglike_init) 

992 

993 if params_free_missing: 

994 columns_param_flux_fit = { 

995 column: param 

996 for column, param in columns_param_flux.items() 

997 if param not in params_free_missing 

998 } 

999 else: 

1000 columns_param_flux_fit = columns_param_flux 

1001 

1002 for column, param in columns_param_flux_fit.items(): 

1003 results[column][idx] = param.value 

1004 else: 

1005 loglike_final = model.evaluate() 

1006 

1007 if convert_cen_xy_to_radec_first: 

1008 for key_ra, key_dec, key_cen_x, key_cen_y in columns_params_radec: 

1009 # These will have been converted back if necessary 

1010 cen_x, cen_y = results[key_cen_x][idx], results[key_cen_y][idx] 

1011 radec = self.get_model_radec(source_multi, cen_x, cen_y) 

1012 results[key_ra][idx], results[key_dec][idx] = radec 

1013 

1014 if fit_psmodel_final: 

1015 cen_x, cen_y = results[key_cen_x_psmodel][idx], results[key_cen_y_psmodel][idx] 

1016 cenx_psmodel.value = cen_x 

1017 ceny_psmodel.value = cen_y 

1018 model_psf = g2f.ModelD(data=data, psfmodels=psf_models, sources=sources_psmodel) 

1019 _ = self.modeller.fit_model_linear(model_psf) 

1020 model_psf.setup_evaluators(evaluatormode=g2f.EvaluatorMode.loglike) 

1021 loglike_psfmodel = model_psf.evaluate() 

1022 # Reset fluxes for the next fit 

1023 for param in fluxes_psmodel.values(): 

1024 param.value = 1.0 

1025 results[f"{prefix}delta_lnL_fit_ps"][idx] = loglike_final[0] - loglike_psfmodel[0] 

1026 

1027 if compute_errors: 

1028 errors = [] 

1029 model_eval = model 

1030 errors_iter = None 

1031 for param in params_free_missing: 

1032 param.fixed = True 

1033 

1034 if config.compute_errors_from_jacobian: 

1035 try: 

1036 errors_iter = np.sqrt( 

1037 self.modeller.compute_variances( 

1038 model_eval, 

1039 transformed=False, 

1040 use_diag_only=config.compute_errors_no_covar, 

1041 ) 

1042 ) 

1043 errors.append((errors_iter, np.sum(~(errors_iter > 0)))) 

1044 except Exception: 

1045 pass 

1046 # If computing errors from the Jacobian didn't work, or if 

1047 # it was disabled in the config, try the Hessian 

1048 if errors_iter is None: 

1049 img_data_old = [] 

1050 if errors_hessian_bestfit: 

1051 # Model sans prior 

1052 model_eval = g2f.ModelD( 

1053 data=model.data, psfmodels=model.psfmodels, sources=model.sources 

1054 ) 

1055 model_eval.setup_evaluators(evaluatormode=g2f.EvaluatorMode.image) 

1056 model_eval.evaluate() 

1057 # Compute the errors by setting the data to the 

1058 # best-fit model (a quasi-parametric bootstrap 

1059 # with one iteration) 

1060 for obs, output in zip(model_eval.data, model_eval.outputs): 

1061 img_data_old.append(obs.image.data.copy()) 

1062 img = obs.image.data 

1063 img.flat = output.data.flat 

1064 # To make this a real bootstrap, could do this 

1065 # (but would need to iterate): 

1066 # + rng.standard_normal(img.size)*( 

1067 # obs.sigma_inv.data.flat) 

1068 

1069 # Try without forcing all of the Hessian terms to be 

1070 # negative first. At the optimum they should be, but 

1071 # in practice the best-fit values are always at least 

1072 # a little off and so the sign is equally likely to be 

1073 # positive as negative. 

1074 for return_negative in (False, True): 

1075 kwargs_err = kwargs_err_default[return_negative] 

1076 if errors and errors[-1][1] == 0: 

1077 break 

1078 try: 

1079 errors_iter = np.sqrt( 

1080 self.modeller.compute_variances( 

1081 model_eval, transformed=False, **kwargs_err 

1082 ) 

1083 ) 

1084 errors.append((errors_iter, np.sum(~(errors_iter > 0)))) 

1085 except Exception: 

1086 try: 

1087 errors_iter = np.sqrt( 

1088 self.modeller.compute_variances( 

1089 model_eval, 

1090 transformed=False, 

1091 use_svd=True, 

1092 **kwargs_err, 

1093 ) 

1094 ) 

1095 errors.append((errors_iter, np.sum(~(errors_iter > 0)))) 

1096 except Exception: 

1097 pass 

1098 # Return the data to its original noisy values 

1099 # (it was replaced by the model earlier) 

1100 if errors_hessian_bestfit: 

1101 for obs, img_datum_old in zip(model.data, img_data_old): 

1102 obs.image.data.flat = img_datum_old.flat 

1103 # Save and optionally plot the errors 

1104 if errors: 

1105 idx_min = np.argmax([err[1] for err in errors]) 

1106 errors = errors[idx_min][0] 

1107 if plot: 

1108 errors_plot = np.clip(errors, 0, 1000) 

1109 errors_plot[~np.isfinite(errors_plot)] = 0 

1110 from ..plotting import ErrorValues, plot_loglike 

1111 

1112 try: 

1113 plot_loglike(model, errors={"err": ErrorValues(values=errors_plot)}) 

1114 except Exception: 

1115 for param in params: 

1116 param.fixed = False 

1117 

1118 if params_free_missing: 

1119 columns_err_fitted = [ 

1120 column 

1121 for column, param in zip(columns_err, params.values()) 

1122 if param not in params_free_missing 

1123 ] 

1124 else: 

1125 columns_err_fitted = columns_err 

1126 

1127 for value, column_err in zip(errors, columns_err_fitted): 

1128 results[column_err][idx] = value 

1129 

1130 for param in params_free_missing: 

1131 param.fixed = False 

1132 

1133 # Convert the x/y errors to ra/dec errors 

1134 if config.convert_cen_xy_to_radec: 

1135 self.compute_model_radec_err( 

1136 source_multi, 

1137 results, 

1138 columns_params_radec_err, 

1139 idx, 

1140 set_radec=not convert_cen_xy_to_radec_first, 

1141 ) 

1142 

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

1144 time_final = time.process_time() 

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

1146 except Exception as e: 

1147 n_skipfail += 1 

1148 size = 0 if fitInputs is None else size_new 

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

1150 if column: 

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

1152 logger.debug( 

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

1154 id_source, 

1155 idx, 

1156 n_rows, 

1157 e, 

1158 ) 

1159 else: 

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

1161 logger.info( 

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

1163 id_source, 

1164 idx, 

1165 n_rows, 

1166 e, 

1167 exc_info=1, 

1168 ) 

1169 logger_periodic.log( 

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

1171 idx, 

1172 n_rows, 

1173 n_skipfail, 

1174 time_final - time_init_all, 

1175 ) 

1176 

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

1178 if n_unknown > 0: 

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

1180 

1181 return results 

1182 

1183 def get_channels( 

1184 self, 

1185 catexps: list[CatalogExposureSourcesABC], 

1186 ) -> dict[str, g2f.Channel]: 

1187 channels = {} 

1188 for catexp in catexps: 

1189 try: 

1190 channel = catexp.channel 

1191 except AttributeError: 

1192 band = catexp.band 

1193 if callable(band): 

1194 band = band() 

1195 channel = g2f.Channel.get(band) 

1196 if channel not in channels: 

1197 channels[channel.name] = channel 

1198 return channels 

1199 

1200 def get_model( 

1201 self, 

1202 idx_row: int, 

1203 catalog_multi: Sequence, 

1204 catexps: list[CatalogExposureSourcesABC], 

1205 config_data: CatalogSourceFitterConfigData | None = None, 

1206 results: astropy.table.Table | None = None, 

1207 **kwargs: Any, 

1208 ) -> g2f.ModelD: 

1209 """Reconstruct the model for a single row of a fit catalog. 

1210 

1211 Parameters 

1212 ---------- 

1213 idx_row 

1214 The index of the row in the catalog. 

1215 catalog_multi 

1216 The multi-band catalog originally used for initialization. 

1217 catexps 

1218 The catalog-exposure pairs to reconstruct the model for. 

1219 config_data 

1220 The configuration used to generate sources. 

1221 Default-initialized if None. 

1222 results 

1223 The corresponding best-fit parameter catalog to initialize 

1224 parameter values from. If None, the model params will be set by 

1225 `self.initialize_model`, as they would be when calling `self.fit`. 

1226 **kwargs 

1227 Additional keyword arguments to pass to initialize_model. Not 

1228 used during fitting. 

1229 

1230 Returns 

1231 ------- 

1232 model 

1233 The reconstructed model. 

1234 """ 

1235 channels = self.get_channels(catexps) 

1236 if config_data is None: 

1237 config_data = CatalogSourceFitterConfigData( 

1238 config=CatalogSourceFitterConfig(), 

1239 channels=list(channels.values()), 

1240 ) 

1241 config = config_data.config 

1242 

1243 if not idx_row >= 0: 

1244 raise ValueError(f"{idx_row=} !>=0") 

1245 if not len(catalog_multi) > idx_row: 

1246 raise ValueError(f"{len(catalog_multi)=} !> {idx_row=}") 

1247 if (results is not None) and not (len(results) > idx_row): 

1248 raise ValueError(f"{len(results)=} !> {idx_row=}") 

1249 

1250 model_sources, priors = config_data.sources_priors 

1251 source_multi = catalog_multi[idx_row] 

1252 

1253 data, psf_models = config.make_model_data( 

1254 idx_row=idx_row, 

1255 catexps=catexps, 

1256 ) 

1257 model = g2f.ModelD(data=data, psfmodels=psf_models, sources=model_sources, priors=priors) 

1258 self.initialize_model(model, source_multi, catexps, **kwargs) 

1259 

1260 if results is not None: 

1261 row = results[idx_row] 

1262 for column, param in config_data.parameters.items(): 

1263 param.value = row[f"{config.prefix_column}{column}"] 

1264 

1265 return model 

1266 

1267 def get_model_radec(self, source: Mapping[str, Any], cen_x: float, cen_y: float) -> tuple[float, float]: 

1268 """Return right ascension and declination values for a source. 

1269 

1270 Implementing this method is necessary only when fitting data with 

1271 accompanying WCS. 

1272 

1273 Parameters 

1274 ---------- 

1275 source 

1276 A mapping with fields expected to be populated in the 

1277 corresponding source catalog. 

1278 cen_x 

1279 The x-axis centroid in pixel coordinates. 

1280 cen_y 

1281 The y-axis centroid in pixel coordinates. 

1282 

1283 Returns 

1284 ------- 

1285 ra, dec 

1286 The right ascension and declination. 

1287 """ 

1288 raise RaDecConversionNotImplementedError("get_model_radec has no default implementation") 

1289 

1290 @abstractmethod 

1291 def initialize_model( 

1292 self, 

1293 model: g2f.ModelD, 

1294 source: Mapping[str, Any], 

1295 catexps: list[CatalogExposureSourcesABC], 

1296 config_data: CatalogSourceFitterConfigData, 

1297 values_init: Mapping[g2f.ParameterD, float] | None = None, 

1298 **kwargs: Any, 

1299 ) -> None: 

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

1301 

1302 Parameters 

1303 ---------- 

1304 model 

1305 The model object to initialize. 

1306 source 

1307 A mapping with fields expected to be populated in the 

1308 corresponding source catalog for initialization. 

1309 catexps 

1310 A list of (source and psf) catalog-exposure pairs. 

1311 config_data 

1312 Configuration settings and data for fitting and output. 

1313 values_init 

1314 Initial parameter values from the model configuration. 

1315 **kwargs 

1316 Additional keyword arguments that cannot be required for fitting. 

1317 """ 

1318 

1319 @abstractmethod 

1320 def validate_fit_inputs( 

1321 self, 

1322 catalog_multi: Sequence, 

1323 catexps: list[CatalogExposureSourcesABC], 

1324 config_data: CatalogSourceFitterConfigData = None, 

1325 logger: logging.Logger = None, 

1326 **kwargs: Any, 

1327 ) -> None: 

1328 """Validate inputs to self.fit. 

1329 

1330 This method is called before any fitting is done. It may be used for 

1331 any purpose, including checking that the inputs are a particular 

1332 subclass of the base classes. 

1333 

1334 Parameters 

1335 ---------- 

1336 catalog_multi 

1337 A multi-band source catalog to fit a model to. 

1338 catexps 

1339 A list of (source and psf) catalog-exposure pairs. 

1340 config_data 

1341 Configuration settings and data for fitting and output. 

1342 logger 

1343 The logger. Defaults to calling `_getlogger`. 

1344 **kwargs 

1345 Additional keyword arguments to pass to self.modeller. 

1346 """ 

1347 pass 

1348 

1349 def validate_source( 

1350 self, 

1351 idx_row: int, 

1352 catalog_multi: Sequence, 

1353 ) -> None: 

1354 """Validate that the source is suitable to fit. 

1355 

1356 Subclasses may override this method to raise a relevant exception 

1357 if the source should be skipped. 

1358 

1359 Parameters 

1360 ---------- 

1361 idx_row 

1362 The index of the row in the multiband catalog. 

1363 catalog_multi 

1364 The multiband input catalog. 

1365 """