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

490 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-07 02:01 -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/>. 

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 

39from ..modelconfig import ModelConfig 

40from ..modeller import FitInputsDummy, Modeller 

41from ..sourceconfig import SourceConfig, ComponentGroupConfig 

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 config_model = pexConfig.ConfigField[ModelConfig](doc="Source model configuration") 

114 convert_cen_xy_to_radec = pexConfig.Field[bool](default=True, doc="Convert cen x/y params to RA/dec") 

115 fit_psmodel_final = pexConfig.Field[bool]( 

116 default=False, 

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

118 ) 

119 prior_cen_x_stddev = pexConfig.Field[float]( 

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

121 ) 

122 prior_cen_y_stddev = pexConfig.Field[float]( 

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

124 ) 

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

126 

127 def make_model_data( 

128 self, 

129 idx_row: int, 

130 catexps: list[CatalogExposureSourcesABC], 

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

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

133 

134 Parameters 

135 ---------- 

136 idx_row 

137 The index of the row in each catalog. 

138 catexps 

139 Catalog-exposure pairs to initialize observations from. 

140 

141 Returns 

142 ------- 

143 data 

144 The resulting data object. 

145 psf_models 

146 A list of psf_models, one per catexp. 

147 

148 Notes 

149 ----- 

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

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

152 """ 

153 observations = [] 

154 psf_models = [] 

155 

156 for catexp in catexps: 

157 catalog = catexp.get_catalog() 

158 # This indicates that there's no corresponding exposure 

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

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

161 if len(catalog) == 0: 

162 continue 

163 source = catalog[idx_row] 

164 observation = catexp.get_source_observation(source) 

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

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

167 if observation is not None: 

168 psf_model = catexp.get_psf_model(source) 

169 if psf_model is not None: 

170 observations.append(observation) 

171 # PSF model parameters cannot be fit along with sources 

172 for param in get_params_uniq(psf_model): 

173 param.fixed = True 

174 psf_models.append(psf_model) 

175 

176 data = g2f.DataD(observations) 

177 return data, psf_models 

178 

179 def make_point_sources( 

180 self, 

181 channels: Iterable[g2f.Channel], 

182 sources: list[g2f.Source], 

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

184 """Make initialized point sources given channels. 

185 

186 Parameters 

187 ---------- 

188 channels 

189 The channels to initialize fluxes for. 

190 sources 

191 List of sources. 

192 

193 Returns 

194 ------- 

195 sources 

196 The list of initialized sources. 

197 priors 

198 The list of priors. 

199 

200 Notes 

201 ----- 

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

203 consistent with make_sources. 

204 """ 

205 point_sources = [] 

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

207 

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

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

210 config_src_psf = SourceConfig( 

211 component_groups={ 

212 "": ComponentGroupConfig( 

213 centroids=centroids, 

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

215 ) 

216 } 

217 ) 

218 source, _ = config_src_psf.make_source(fluxes) 

219 point_sources.append(source) 

220 

221 return point_sources, [] 

222 

223 def make_sources( 

224 self, 

225 channels: Iterable[g2f.Channel], 

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

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

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

229 

230 Parameters 

231 ---------- 

232 channels 

233 The channels to initialize fluxes for. 

234 source_fluxes 

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

236 source. The default is to initialize using 

237 `ComponentGroupConfig.get_fluxes_default`. 

238 

239 Returns 

240 ------- 

241 sources 

242 The list of initialized sources. 

243 priors 

244 The list of priors. 

245 """ 

246 n_sources = len(self.config_model.sources) 

247 if source_fluxes is None: 

248 source_fluxes = [None] * n_sources 

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

250 zip( 

251 self.config_model.sources.values(), 

252 source_fluxes, 

253 ) 

254 ): 

255 component_group_fluxes = [ 

256 component_group.get_fluxes_default( 

257 channels=channels, 

258 component_configs=component_group.get_component_configs(), 

259 is_fractional=component_group.is_fractional, 

260 ) 

261 for component_group in config_source.component_groups.values() 

262 ] 

263 source_fluxes[idx] = component_group_fluxes 

264 else: 

265 if len(source_fluxes) != n_sources: 

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

267 

268 sources, priors = self.config_model.make_sources( 

269 component_group_fluxes_srcs=source_fluxes, 

270 ) 

271 

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

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

274 if has_prior_x or has_prior_y: 

275 for source in sources: 

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

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

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

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

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

281 

282 return sources, priors 

283 

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

285 columns = [] 

286 if self.config_fit.eval_residual: 

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

288 if self.fit_linear_final: 

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

290 if self.fit_psmodel_final: 

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

292 return columns 

293 

294 def schema( 

295 self, 

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

297 ) -> list[ColumnInfo]: 

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

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

300 schema = super().schema(bands) 

301 

302 parameters = CatalogSourceFitterConfigData( 

303 config=self, 

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

305 ).parameters 

306 unit_size = u.Unit("pix") 

307 units = { 

308 g2f.IntegralParameterD: self.unit_flux, 

309 g2f.ReffXParameterD: unit_size, 

310 g2f.ReffYParameterD: unit_size, 

311 g2f.SizeXParameterD: unit_size, 

312 g2f.SizeYParameterD: unit_size, 

313 } 

314 idx_start = len(schema) 

315 schema.extend( 

316 [ 

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

318 for key, param in parameters.items() 

319 ] 

320 ) 

321 if self.convert_cen_xy_to_radec: 

322 label_cen = self.get_key_cen() 

323 cen_underscored = label_cen.startswith("_") 

324 suffix_x, suffix_y, suffix_ra, suffix_dec = ( 

325 f"{label_cen}{suffix}" 

326 for suffix in ( 

327 self.get_suffix_x(), 

328 self.get_suffix_y(), 

329 self.get_suffix_ra(), 

330 self.get_suffix_dec(), 

331 ) 

332 ) 

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

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

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

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

337 # ... or arbitrary rotations 

338 suffix_radec, suffix_xy = ( 

339 (suffix_ra, suffix_x) 

340 if isinstance(param, g2f.CentroidXParameterD) 

341 else ( 

342 (suffix_dec, suffix_y) if isinstance(param, g2f.CentroidYParameterD) else (None, None) 

343 ) 

344 ) 

345 if suffix_radec is not None: 

346 # Add whatever the corresponding prefix is, and also 

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

348 prefix, suffix = ( 

349 ("", suffix_radec[1:]) 

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

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

352 ) 

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

354 if self.compute_errors != "NONE": 

355 suffix = self.suffix_error 

356 idx_end = len(schema) 

357 # Do not remove idx_end unless you like infinite recursion 

358 for column in schema[idx_start:idx_end]: 

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

360 

361 schema.extend(self.schema_configurable()) 

362 return schema 

363 

364 

365class CatalogSourceFitterConfigData(pydantic.BaseModel): 

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

367 models and images thereof. 

368 

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

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

371 ensure that the model remains unchanged. 

372 """ 

373 

374 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

375 

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

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

378 

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

380 def validate_config(self) -> Self: 

381 self.config.validate() 

382 return self 

383 

384 @cached_property 

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

386 sources = self.sources_priors[0] 

387 components = [] 

388 for source in sources: 

389 components.extend(source.components) 

390 return components 

391 

392 @cached_property 

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

394 config = self.config 

395 config_model = config.config_model 

396 idx_comp_first = 0 

397 has_prefix_source = config_model.has_prefix_source() 

398 n_channels = len(self.channels) 

399 parameters = {} 

400 

401 label_cen = config.get_key_cen() 

402 label_rho = config.get_key_rho() 

403 label_sersic = config.get_key_sersicindex() 

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

405 

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

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

408 has_prefix_group = config_source.has_prefix_group() 

409 

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

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

412 multicen = len(config_group.centroids) > 1 

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

414 

415 is_multicomp = len(configs_comp) > 1 

416 

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

418 component = self.components[idx_comp_first + idx_comp_group] 

419 

420 key_comp = name_comp if is_multicomp else "" 

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

422 key_size = config.get_prefixed_label( 

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

424 prefix_comp, 

425 ) 

426 key_rho = config.get_prefixed_label(label_rho, prefix_comp) 

427 

428 if multicen or (idx_comp_group == 0): 

429 prefix_cen = prefix_comp if multicen else prefix_group 

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

431 # prefix or an existing prefix 

432 key_cen = config.get_prefixed_label(label_cen, prefix_cen) 

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

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

435 if not config_comp.size_x.fixed: 

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

437 if not config_comp.size_y.fixed: 

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

439 if not config_comp.rho.fixed: 

440 parameters[key_rho] = component.ellipse.rho_param 

441 if not config_comp.flux.fixed: 

442 # TODO: return this to component.integralmodel 

443 # when binding for g2f.FractionalIntegralModel is fixed 

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

445 if len(params_flux) != n_channels: 

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

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

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

449 parameters[key_flux] = param_flux 

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

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

452 component.sersicindex_param 

453 ) 

454 

455 return parameters 

456 

457 @cached_property 

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

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

460 return tuple(sources), tuple(priors) 

461 

462 

463class CatalogSourceFitterABC(ABC, pydantic.BaseModel): 

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

465 

466 Notes 

467 ----- 

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

469 generic unknown_flag failure column. 

470 """ 

471 

472 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

473 

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

475 default_factory=dict, 

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

477 ) 

478 modeller: Modeller = pydantic.Field( 

479 default_factory=Modeller, 

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

481 ) 

482 

483 def _get_columns_params_radec( 

484 self, 

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

486 compute_errors: bool, 

487 config: CatalogSourceFitterConfig, 

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

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

490 parameters into ra/dec. 

491 

492 Parameters 

493 ---------- 

494 params_radec 

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

496 compute_errors 

497 Whether errors will be computed. 

498 config 

499 The configuration with column formatting parameters. 

500 

501 Returns 

502 ------- 

503 columns_params_radec 

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

505 columns_params_radec_err 

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

507 """ 

508 columns_params_radec = [] 

509 columns_params_radec_err = [] 

510 suffix_err = config.suffix_error 

511 key_cen = config.get_key_cen() 

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

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

514 

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

516 # Avoid C if there's nothing to prefix 

517 # or an existing prefix ending in an underscore 

518 key_base_cen = config.get_prefixed_label(f"{key_base}{key_cen}", config.prefix_column) 

519 

520 if param_cen_y is None: 

521 raise RuntimeError( 

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

523 ) 

524 columns_params_radec.append( 

525 ( 

526 f"{key_base_cen}{suffix_ra}", 

527 f"{key_base_cen}{suffix_dec}", 

528 f"{key_base_cen}{suffix_x}", 

529 f"{key_base_cen}{suffix_y}", 

530 ) 

531 ) 

532 if compute_errors: 

533 columns_params_radec_err.append( 

534 ( 

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

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

537 f"{key_base_cen}{suffix_x}", 

538 f"{key_base_cen}{suffix_y}", 

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

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

541 ) 

542 ) 

543 return columns_params_radec, columns_params_radec_err 

544 

545 @staticmethod 

546 def _get_logger() -> logging.Logger: 

547 logger = logging.getLogger(__name__) 

548 

549 return logger 

550 

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

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

553 

554 Parameters 

555 ---------- 

556 config 

557 The fitting configuration. 

558 

559 Raises 

560 ------ 

561 ValueError 

562 Raised if the configuration is invalid. 

563 """ 

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

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

566 errors_bad = {} 

567 errors_recast = {} 

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

569 if error_type in errors_recast: 

570 errors_bad[error_name] = error_type 

571 else: 

572 errors_recast[error_type] = error_name 

573 if errors_bad: 

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

575 

576 def copy_centroid_errors( 

577 self, 

578 columns_cenx_err_copy: tuple[str], 

579 columns_ceny_err_copy: tuple[str], 

580 results: Table, 

581 catalog_multi: Sequence, 

582 catexps: list[CatalogExposureSourcesABC], 

583 config_data: CatalogSourceFitterConfigData, 

584 ) -> None: 

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

586 

587 This method exists to support fitting models with fixed centroids 

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

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

590 however, there is no reasonable default implementation. 

591 

592 Parameters 

593 ---------- 

594 columns_cenx_err_copy 

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

596 columns_ceny_err_copy 

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

598 results 

599 The table of fit results to copy errors into. 

600 catalog_multi 

601 The input multiband catalog. 

602 catexps 

603 The input data. 

604 config_data 

605 The fitter config and data. 

606 

607 Raises 

608 ------ 

609 NotImplementedError 

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

611 available. 

612 """ 

613 if columns_cenx_err_copy or columns_ceny_err_copy: 

614 raise NotImplementedError( 

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

616 f" but has not overriden copy_centroid_errors" 

617 ) 

618 

619 def fit( 

620 self, 

621 catalog_multi: Sequence, 

622 catexps: list[CatalogExposureSourcesABC], 

623 config_data: CatalogSourceFitterConfigData | None = None, 

624 logger: logging.Logger | None = None, 

625 **kwargs: Any, 

626 ) -> astropy.table.Table: 

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

628 

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

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

631 model parameters and a deconvolved source image from the 

632 CatalogExposureSources. 

633 

634 Parameters 

635 ---------- 

636 catalog_multi 

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

638 catexps 

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

640 config_data 

641 Configuration settings and data for fitting and output. 

642 logger 

643 The logger. Defaults to calling `_getlogger`. 

644 **kwargs 

645 Additional keyword arguments to pass to self.modeller. 

646 

647 Returns 

648 ------- 

649 catalog : `astropy.Table` 

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

651 of each source. 

652 """ 

653 if config_data is None: 

654 config_data = CatalogSourceFitterConfigData( 

655 config=CatalogSourceFitterConfig(), 

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

657 ) 

658 if logger is None: 

659 logger = self._get_logger() 

660 

661 config = config_data.config 

662 self._validate_errors_expected(config) 

663 self.validate_fit_inputs( 

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

665 ) 

666 

667 model_sources, priors = config_data.sources_priors 

668 if config.fit_psmodel_final: 

669 channels = config_data.channels 

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

671 params_psmodel = sources_psmodel[0].parameters() 

672 cenx_psmodel, ceny_psmodel = None, None 

673 fluxes_psmodel = {} 

674 idx_band = 0 

675 for param in params_psmodel: 

676 if isinstance(param, g2f.CentroidXParameterD): 

677 if cenx_psmodel is not None: 

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

679 cenx_psmodel = param 

680 elif isinstance(param, g2f.CentroidYParameterD): 

681 if ceny_psmodel is not None: 

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

683 ceny_psmodel = param 

684 elif isinstance(param, g2f.IntegralParameterD): 

685 fluxes_psmodel[channels[idx_band]] = param 

686 idx_band += 1 

687 

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

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

690 params = config_data.parameters 

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

692 prefix = config.prefix_column 

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

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

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

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

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

698 columns_err = [] 

699 

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

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

702 compute_errors: bool = errors_hessian or errors_hessian_bestfit 

703 

704 columns_cenx_err_copy = [] 

705 columns_ceny_err_copy = [] 

706 

707 suffix_err = config.suffix_error 

708 key_cen = config.get_key_cen() 

709 cen_underscored = key_cen.startswith("_") 

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

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

712 

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

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

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

716 is_cenx = isinstance(param, g2f.CentroidXParameterD) 

717 is_ceny = isinstance(param, g2f.CentroidYParameterD) 

718 

719 # Add the corresponding error key to the appropriate list 

720 if compute_errors: 

721 if param.free: 

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

723 elif is_cenx: 

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

725 elif is_ceny: 

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

727 

728 # Add this param to the appropriate dict 

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

730 param, 

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

732 ) 

733 if isinstance(param, g2f.IntegralParameterD): 

734 columns_param_flux[key_full] = param 

735 elif config.convert_cen_xy_to_radec: 

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

737 # leading underscore in case there's no prefix 

738 if is_cenx: 

739 prefix_cen, suffix_cen = ( 

740 ("", key_full) 

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

742 else key_full.split(suffix_cenx) 

743 ) 

744 params_cen_x[prefix_cen] = param 

745 elif is_ceny: 

746 prefix_cen, suffix_cen = ( 

747 ("", key_full) 

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

749 else key_full.split(suffix_ceny) 

750 ) 

751 params_cen_y[prefix_cen] = param 

752 

753 if config.convert_cen_xy_to_radec: 

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

755 columns_params_radec, columns_params_radec_err = self._get_columns_params_radec( 

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

757 compute_errors, 

758 config=config, 

759 ) 

760 

761 # Setup the results table with correct column names 

762 n_rows = len(catalog_multi) 

763 channels = self.get_channels(catexps) 

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

765 

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

767 self.copy_centroid_errors( 

768 columns_cenx_err_copy=columns_cenx_err_copy, 

769 columns_ceny_err_copy=columns_ceny_err_copy, 

770 results=results, 

771 catalog_multi=catalog_multi, 

772 catexps=catexps, 

773 config_data=config_data, 

774 ) 

775 

776 # dummy size for first iteration 

777 size, size_new = 0, 0 

778 fitInputs = FitInputsDummy() 

779 plot = False 

780 

781 # Configure default options for calls to compute_variances 

782 # keys are for values of return_negative 

783 kwargs_err_default = { 

784 True: { 

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

786 "use_diag_only": config.compute_errors_no_covar, 

787 }, 

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

789 } 

790 

791 range_idx = range(n_rows) 

792 

793 # TODO: Do this check with dummy data 

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

795 # data, psf_models = config.make_model_data( 

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

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

798 # sources=model_sources, priors=priors) 

799 # Remember to filter out fixed centroids from params 

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

801 

802 time_init_all = time.process_time() 

803 logger_periodic = PeriodicLogger(logger) 

804 n_skipfail = 0 

805 

806 for idx in range_idx: 

807 time_init = time.process_time() 

808 row = results[idx] 

809 source_multi = catalog_multi[idx] 

810 id_source = source_multi[config.column_id] 

811 row[config.column_id] = id_source 

812 time_final = time_init 

813 

814 try: 

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

816 if data.size == 0: 

817 raise NoDataError("make_model_data returned empty data") 

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

819 self.initialize_model( 

820 model, 

821 source_multi, 

822 catexps, 

823 config_data=config_data, 

824 values_init=values_init, 

825 ) 

826 

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

828 # Note: this will need to change with priors 

829 # (data should report its own size) 

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

831 if size_new != size: 

832 fitInputs = None 

833 size = size_new 

834 # Some algorithms might not even use fitInputs 

835 elif fitInputs is not None: 

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

837 

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

839 # appropriately if config.fit_linear_init is False 

840 if config.fit_linear_init: 

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

842 

843 for observation in data: 

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

845 

846 result_full = self.modeller.fit_model( 

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

848 ) 

849 fitInputs = result_full.inputs 

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

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

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

853 if config.config_fit.eval_residual: 

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

855 

856 params_free_missing = result_full.params_free_missing or tuple() 

857 

858 # Set all params to best fit values 

859 # In case the optimizer doesn't 

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

861 columns_param_free.items(), 

862 result_full.params_best, 

863 ): 

864 param.value_transformed = value 

865 if param not in params_free_missing: 

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

867 

868 # Also add any offset to the fixed parameters 

869 # (usually centroids, if any) 

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

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

872 

873 # Do a final linear fit 

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

875 if config.fit_linear_final: 

876 loglike_init, loglike_new = self.modeller.fit_model_linear( 

877 model=model, ratio_min=0.01, validate=True 

878 ) 

879 loglike_final = max(loglike_init, loglike_new) 

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

881 

882 if params_free_missing: 

883 columns_param_flux_fit = { 

884 column: param 

885 for column, param in columns_param_flux.items() 

886 if param not in params_free_missing 

887 } 

888 else: 

889 columns_param_flux_fit = columns_param_flux 

890 

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

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

893 else: 

894 loglike_final = model.evaluate() 

895 

896 if config.convert_cen_xy_to_radec: 

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

898 # These will have been converted back if necessary 

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

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

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

902 

903 if config.fit_psmodel_final: 

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

905 cenx_psmodel.value = cen_x 

906 ceny_psmodel.value = cen_y 

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

908 _ = self.modeller.fit_model_linear(model_psf) 

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

910 loglike_psfmodel = model_psf.evaluate() 

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

912 

913 if compute_errors: 

914 errors = [] 

915 model_eval = model 

916 errors_iter = None 

917 for param in params_free_missing: 

918 param.fixed = True 

919 

920 if config.compute_errors_from_jacobian: 

921 try: 

922 errors_iter = np.sqrt( 

923 self.modeller.compute_variances( 

924 model_eval, 

925 transformed=False, 

926 use_diag_only=config.compute_errors_no_covar, 

927 ) 

928 ) 

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

930 except Exception: 

931 pass 

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

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

934 if errors_iter is None: 

935 img_data_old = [] 

936 if errors_hessian_bestfit: 

937 # Model sans prior 

938 model_eval = g2f.ModelD( 

939 data=model.data, psfmodels=model.psfmodels, sources=model.sources 

940 ) 

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

942 model_eval.evaluate() 

943 # Compute the errors by setting the data to the 

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

945 # with one iteration) 

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

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

948 img = obs.image.data 

949 img.flat = output.data.flat 

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

951 # (but would need to iterate): 

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

953 # obs.sigma_inv.data.flat) 

954 

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

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

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

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

959 # positive as negative. 

960 for return_negative in (False, True): 

961 kwargs_err = kwargs_err_default[return_negative] 

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

963 break 

964 try: 

965 errors_iter = np.sqrt( 

966 self.modeller.compute_variances( 

967 model_eval, transformed=False, **kwargs_err 

968 ) 

969 ) 

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

971 except Exception: 

972 try: 

973 errors_iter = np.sqrt( 

974 self.modeller.compute_variances( 

975 model_eval, 

976 transformed=False, 

977 use_svd=True, 

978 **kwargs_err, 

979 ) 

980 ) 

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

982 except Exception: 

983 pass 

984 # Return the data to its original noisy values 

985 # (it was replaced by the model earlier) 

986 if errors_hessian_bestfit: 

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

988 obs.image.data.flat = img_datum_old.flat 

989 # Save and optionally plot the errors 

990 if errors: 

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

992 errors = errors[idx_min][0] 

993 if plot: 

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

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

996 from ..plotting import ErrorValues, plot_loglike 

997 

998 try: 

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

1000 except Exception: 

1001 for param in params: 

1002 param.fixed = False 

1003 

1004 if params_free_missing: 

1005 columns_err_fitted = [ 

1006 column 

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

1008 if param not in params_free_missing 

1009 ] 

1010 else: 

1011 columns_err_fitted = columns_err 

1012 

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

1014 results[column_err][idx] = value 

1015 

1016 for param in params_free_missing: 

1017 param.fixed = False 

1018 

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

1020 if config.convert_cen_xy_to_radec: 

1021 for ( 

1022 key_ra_err, 

1023 key_dec_err, 

1024 key_cen_x, 

1025 key_cen_y, 

1026 key_cen_x_err, 

1027 key_cen_y_err, 

1028 ) in columns_params_radec_err: 

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

1030 # TODO: improve this in DM-45682 

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

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

1033 # Could also consider dividing by sqrt(2) 

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

1035 radec_err = np.array( 

1036 self.get_model_radec( 

1037 source_multi, 

1038 cen_x + results[key_cen_x_err][idx], 

1039 cen_y + results[key_cen_y_err][idx], 

1040 ) 

1041 ) 

1042 radec_err -= radec 

1043 results[key_ra_err][idx], results[key_dec_err][idx] = np.abs(radec_err) 

1044 

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

1046 time_final = time.process_time() 

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

1048 except Exception as e: 

1049 n_skipfail += 1 

1050 size = 0 if fitInputs is None else size_new 

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

1052 if column: 

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

1054 logger.debug( 

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

1056 id_source, 

1057 idx, 

1058 n_rows, 

1059 e, 

1060 ) 

1061 else: 

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

1063 logger.info( 

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

1065 id_source, 

1066 idx, 

1067 n_rows, 

1068 e, 

1069 exc_info=1, 

1070 ) 

1071 logger_periodic.log( 

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

1073 idx, 

1074 n_rows, 

1075 n_skipfail, 

1076 time_final - time_init_all, 

1077 ) 

1078 

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

1080 if n_unknown > 0: 

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

1082 

1083 return results 

1084 

1085 def get_channels( 

1086 self, 

1087 catexps: list[CatalogExposureSourcesABC], 

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

1089 channels = {} 

1090 for catexp in catexps: 

1091 try: 

1092 channel = catexp.channel 

1093 except AttributeError: 

1094 band = catexp.band 

1095 if callable(band): 

1096 band = band() 

1097 channel = g2f.Channel.get(band) 

1098 if channel not in channels: 

1099 channels[channel.name] = channel 

1100 return channels 

1101 

1102 def get_model( 

1103 self, 

1104 idx_row: int, 

1105 catalog_multi: Sequence, 

1106 catexps: list[CatalogExposureSourcesABC], 

1107 config_data: CatalogSourceFitterConfigData | None = None, 

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

1109 **kwargs: Any, 

1110 ) -> g2f.ModelD: 

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

1112 

1113 Parameters 

1114 ---------- 

1115 idx_row 

1116 The index of the row in the catalog. 

1117 catalog_multi 

1118 The multi-band catalog originally used for initialization. 

1119 catexps 

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

1121 config_data 

1122 The configuration used to generate sources. 

1123 Default-initialized if None. 

1124 results 

1125 The corresponding best-fit parameter catalog to initialize 

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

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

1128 **kwargs 

1129 Additional keyword arguments to pass to initialize_model. Not 

1130 used during fitting. 

1131 

1132 Returns 

1133 ------- 

1134 model 

1135 The reconstructed model. 

1136 """ 

1137 channels = self.get_channels(catexps) 

1138 if config_data is None: 

1139 config_data = CatalogSourceFitterConfigData( 

1140 config=CatalogSourceFitterConfig(), 

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

1142 ) 

1143 config = config_data.config 

1144 

1145 if not idx_row >= 0: 

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

1147 if not len(catalog_multi) > idx_row: 

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

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

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

1151 

1152 model_sources, priors = config_data.sources_priors 

1153 source_multi = catalog_multi[idx_row] 

1154 

1155 data, psf_models = config.make_model_data( 

1156 idx_row=idx_row, 

1157 catexps=catexps, 

1158 ) 

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

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

1161 

1162 if results is not None: 

1163 row = results[idx_row] 

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

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

1166 

1167 return model 

1168 

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

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

1171 

1172 Implementing this method is necessary only when fitting data with 

1173 accompanying WCS. 

1174 

1175 Parameters 

1176 ---------- 

1177 source 

1178 A mapping with fields expected to be populated in the 

1179 corresponding source catalog. 

1180 cen_x 

1181 The x-axis centroid in pixel coordinates. 

1182 cen_y 

1183 The y-axis centroid in pixel coordinates. 

1184 

1185 Returns 

1186 ------- 

1187 ra, dec 

1188 The right ascension and declination. 

1189 """ 

1190 return np.nan, np.nan 

1191 

1192 @abstractmethod 

1193 def initialize_model( 

1194 self, 

1195 model: g2f.ModelD, 

1196 source: Mapping[str, Any], 

1197 catexps: list[CatalogExposureSourcesABC], 

1198 config_data: CatalogSourceFitterConfigData, 

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

1200 **kwargs: Any, 

1201 ) -> None: 

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

1203 

1204 Parameters 

1205 ---------- 

1206 model 

1207 The model object to initialize. 

1208 source 

1209 A mapping with fields expected to be populated in the 

1210 corresponding source catalog for initialization. 

1211 catexps 

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

1213 config_data 

1214 Configuration settings and data for fitting and output. 

1215 values_init 

1216 Initial parameter values from the model configuration. 

1217 **kwargs 

1218 Additional keyword arguments that cannot be required for fitting. 

1219 """ 

1220 

1221 @abstractmethod 

1222 def validate_fit_inputs( 

1223 self, 

1224 catalog_multi: Sequence, 

1225 catexps: list[CatalogExposureSourcesABC], 

1226 config_data: CatalogSourceFitterConfigData = None, 

1227 logger: logging.Logger = None, 

1228 **kwargs: Any, 

1229 ) -> None: 

1230 """Validate inputs to self.fit. 

1231 

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

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

1234 subclass of the base classes. 

1235 

1236 Parameters 

1237 ---------- 

1238 catalog_multi 

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

1240 catexps 

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

1242 config_data 

1243 Configuration settings and data for fitting and output. 

1244 logger 

1245 The logger. Defaults to calling `_getlogger`. 

1246 **kwargs 

1247 Additional keyword arguments to pass to self.modeller. 

1248 """ 

1249 pass