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

447 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:36 -0700

1# This file is part of multiprofit. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

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 

38from ..errors import NoDataError 

39from ..modelconfig import ModelConfig 

40from ..modeller import FitInputsDummy, Modeller 

41from ..utils import frozen_arbitrary_allowed_config, get_params_uniq 

42from .fit_catalog import CatalogExposureABC, CatalogFitterConfig, ColumnInfo 

43 

44__all__ = [ 

45 "CatalogExposureSourcesABC", 

46 "CatalogSourceFitterConfig", 

47 "CatalogSourceFitterConfigData", 

48 "CatalogSourceFitterABC", 

49] 

50 

51 

52class CatalogExposureSourcesABC(CatalogExposureABC): 

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

54 

55 @property 

56 def band(self) -> str: 

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

58 return self.channel.name 

59 

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

61 @property 

62 @abstractmethod 

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

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

65 

66 @abstractmethod 

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

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

69 

70 Parameters 

71 ---------- 

72 params : Mapping[str, Any] 

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

74 centroid of a single source. 

75 

76 Returns 

77 ------- 

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

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

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

81 fit table has a flag set). 

82 """ 

83 

84 @abstractmethod 

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

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

87 

88 Parameters 

89 ---------- 

90 source : Mapping[str, Any] 

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

92 single source. 

93 **kwargs 

94 Additional keyword arguments not used during fitting. 

95 

96 Returns 

97 ------- 

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

99 An Observation object with suitable data for fitting parametric 

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

101 """ 

102 

103 

104class CatalogSourceFitterConfig(CatalogFitterConfig): 

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

106 

107 centroid_pixel_offset = pexConfig.Field[float]( 

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

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

110 default=0, 

111 ) 

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

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

114 prior_cen_x_stddev = pexConfig.Field[float]( 

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

116 ) 

117 prior_cen_y_stddev = pexConfig.Field[float]( 

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

119 ) 

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

121 

122 def make_model_data( 

123 self, 

124 idx_row: int, 

125 catexps: list[CatalogExposureSourcesABC], 

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

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

128 

129 Parameters 

130 ---------- 

131 idx_row 

132 The index of the row in each catalog. 

133 catexps 

134 Catalog-exposure pairs to initialize observations from. 

135 

136 Returns 

137 ------- 

138 data 

139 The resulting data object. 

140 psf_models 

141 A list of psf_models, one per catexp. 

142 

143 Notes 

144 ----- 

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

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

147 """ 

148 observations = [] 

149 psf_models = [] 

150 

151 for catexp in catexps: 

152 catalog = catexp.get_catalog() 

153 # This indicates that there's no corresponding exposure 

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

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

156 if len(catalog) == 0: 

157 continue 

158 source = catalog[idx_row] 

159 observation = catexp.get_source_observation(source) 

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

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

162 if observation is not None: 

163 psf_model = catexp.get_psf_model(source) 

164 if psf_model is not None: 

165 observations.append(observation) 

166 # PSF model parameters cannot be fit along with sources 

167 for param in get_params_uniq(psf_model): 

168 param.fixed = True 

169 psf_models.append(psf_model) 

170 

171 data = g2f.DataD(observations) 

172 return data, psf_models 

173 

174 def make_sources( 

175 self, 

176 channels: Iterable[g2f.Channel], 

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

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

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

180 

181 Parameters 

182 ---------- 

183 channels 

184 The channels to initialize fluxes for. 

185 source_fluxes 

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

187 source. The default is to initialize using 

188 `ComponentGroupConfig.get_fluxes_default`. 

189 

190 Returns 

191 ------- 

192 sources 

193 The list of initialized sources. 

194 priors 

195 The list of priors. 

196 """ 

197 n_sources = len(self.config_model.sources) 

198 if source_fluxes is None: 

199 source_fluxes = [None] * n_sources 

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

201 zip( 

202 self.config_model.sources.values(), 

203 source_fluxes, 

204 ) 

205 ): 

206 component_group_fluxes = [ 

207 component_group.get_fluxes_default( 

208 channels=channels, 

209 component_configs=component_group.get_component_configs(), 

210 is_fractional=component_group.is_fractional, 

211 ) 

212 for component_group in config_source.component_groups.values() 

213 ] 

214 source_fluxes[idx] = component_group_fluxes 

215 else: 

216 if len(source_fluxes) != n_sources: 

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

218 

219 sources, priors = self.config_model.make_sources( 

220 component_group_fluxes_srcs=source_fluxes, 

221 ) 

222 

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

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

225 if has_prior_x or has_prior_y: 

226 for source in sources: 

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

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

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

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

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

232 

233 return sources, priors 

234 

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

236 columns = [] 

237 if self.config_fit.eval_residual: 

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

239 if self.fit_linear_final: 

240 columns.append(ColumnInfo(key="delta_ll_fit_linear", dtype="f8")) 

241 return columns 

242 

243 def schema( 

244 self, 

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

246 ) -> list[ColumnInfo]: 

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

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

249 schema = super().schema(bands) 

250 

251 parameters = CatalogSourceFitterConfigData( 

252 config=self, 

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

254 ).parameters 

255 unit_size = u.Unit("pix") 

256 units = { 

257 g2f.IntegralParameterD: self.unit_flux, 

258 g2f.ReffXParameterD: unit_size, 

259 g2f.ReffYParameterD: unit_size, 

260 g2f.SizeXParameterD: unit_size, 

261 g2f.SizeYParameterD: unit_size, 

262 } 

263 idx_start = len(schema) 

264 schema.extend( 

265 [ 

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

267 for key, param in parameters.items() 

268 ] 

269 ) 

270 if self.convert_cen_xy_to_radec: 

271 label_cen = self.get_key_cen() 

272 cen_underscored = label_cen.startswith("_") 

273 suffix_x, suffix_y, suffix_ra, suffix_dec = ( 

274 f"{label_cen}{suffix}" 

275 for suffix in ( 

276 self.get_suffix_x(), 

277 self.get_suffix_y(), 

278 self.get_suffix_ra(), 

279 self.get_suffix_dec(), 

280 ) 

281 ) 

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

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

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

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

286 # ... or arbitrary rotations 

287 suffix_radec, suffix_xy = ( 

288 (suffix_ra, suffix_x) 

289 if isinstance(param, g2f.CentroidXParameterD) 

290 else ( 

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

292 ) 

293 ) 

294 if suffix_radec is not None: 

295 # Add whatever the corresponding prefix is, and also 

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

297 prefix, suffix = ( 

298 ("", suffix_radec[1:]) 

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

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

301 ) 

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

303 if self.compute_errors != "NONE": 

304 suffix = self.suffix_error 

305 idx_end = len(schema) 

306 # Do not remove idx_end unless you like infinite recursion 

307 for column in schema[idx_start:idx_end]: 

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

309 

310 schema.extend(self.schema_configurable()) 

311 return schema 

312 

313 

314class CatalogSourceFitterConfigData(pydantic.BaseModel): 

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

316 models and images thereof. 

317 

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

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

320 ensure that the model remains unchanged. 

321 """ 

322 

323 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

324 

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

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

327 

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

329 def validate_config(self) -> Self: 

330 self.config.validate() 

331 return self 

332 

333 @cached_property 

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

335 sources = self.sources_priors[0] 

336 components = [] 

337 for source in sources: 

338 components.extend(source.components) 

339 return components 

340 

341 @cached_property 

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

343 config = self.config 

344 config_model = config.config_model 

345 idx_comp_first = 0 

346 has_prefix_source = config_model.has_prefix_source() 

347 n_channels = len(self.channels) 

348 parameters = {} 

349 

350 label_cen = config.get_key_cen() 

351 label_rho = config.get_key_rho() 

352 label_sersic = config.get_key_sersicindex() 

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

354 

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

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

357 has_prefix_group = config_source.has_prefix_group() 

358 

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

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

361 multicen = len(config_group.centroids) > 1 

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

363 

364 is_multicomp = len(configs_comp) > 1 

365 

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

367 component = self.components[idx_comp_first + idx_comp_group] 

368 

369 key_comp = name_comp if is_multicomp else "" 

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

371 key_size = config.get_prefixed_label( 

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

373 prefix_comp, 

374 ) 

375 key_rho = config.get_prefixed_label(label_rho, prefix_comp) 

376 

377 if multicen or (idx_comp_group == 0): 

378 prefix_cen = prefix_comp if multicen else prefix_group 

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

380 # prefix or an existing prefix 

381 key_cen = config.get_prefixed_label(label_cen, prefix_cen) 

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

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

384 if not config_comp.size_x.fixed: 

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

386 if not config_comp.size_y.fixed: 

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

388 if not config_comp.rho.fixed: 

389 parameters[key_rho] = component.ellipse.rho_param 

390 if not config_comp.flux.fixed: 

391 # TODO: return this to component.integralmodel 

392 # when binding for g2f.FractionalIntegralModel is fixed 

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

394 if len(params_flux) != n_channels: 

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

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

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

398 parameters[key_flux] = param_flux 

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

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

401 component.sersicindex_param 

402 ) 

403 

404 return parameters 

405 

406 @cached_property 

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

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

409 return tuple(sources), tuple(priors) 

410 

411 

412class CatalogSourceFitterABC(ABC, pydantic.BaseModel): 

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

414 

415 Notes 

416 ----- 

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

418 generic unknown_flag failure column. 

419 """ 

420 

421 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

422 

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

424 default_factory=dict, 

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

426 ) 

427 modeller: Modeller = pydantic.Field( 

428 default_factory=Modeller, 

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

430 ) 

431 

432 def _get_columns_params_radec( 

433 self, 

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

435 compute_errors: bool, 

436 config: CatalogSourceFitterConfig, 

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

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

439 parameters into ra/dec. 

440 

441 Parameters 

442 ---------- 

443 params_radec 

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

445 compute_errors 

446 Whether errors will be computed. 

447 config 

448 The configuration with column formatting parameters. 

449 

450 Returns 

451 ------- 

452 columns_params_radec 

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

454 columns_params_radec_err 

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

456 """ 

457 columns_params_radec = [] 

458 columns_params_radec_err = [] 

459 suffix_err = config.suffix_error 

460 key_cen = config.get_key_cen() 

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

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

463 

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

465 # Avoid C if there's nothing to prefix 

466 # or an existing prefix ending in an underscore 

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

468 

469 if param_cen_y is None: 

470 raise RuntimeError( 

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

472 ) 

473 columns_params_radec.append( 

474 ( 

475 f"{key_base_cen}{suffix_ra}", 

476 f"{key_base_cen}{suffix_dec}", 

477 f"{key_base_cen}{suffix_x}", 

478 f"{key_base_cen}{suffix_y}", 

479 ) 

480 ) 

481 if compute_errors: 

482 columns_params_radec_err.append( 

483 ( 

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

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

486 f"{key_base_cen}{suffix_x}", 

487 f"{key_base_cen}{suffix_y}", 

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

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

490 ) 

491 ) 

492 return columns_params_radec, columns_params_radec_err 

493 

494 @staticmethod 

495 def _get_logger() -> logging.Logger: 

496 logger = logging.getLogger(__name__) 

497 

498 return logger 

499 

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

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

502 

503 Parameters 

504 ---------- 

505 config 

506 The fitting configuration. 

507 

508 Raises 

509 ------ 

510 ValueError 

511 Raised if the configuration is invalid. 

512 """ 

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

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

515 errors_bad = {} 

516 errors_recast = {} 

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

518 if error_type in errors_recast: 

519 errors_bad[error_name] = error_type 

520 else: 

521 errors_recast[error_type] = error_name 

522 if errors_bad: 

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

524 

525 def copy_centroid_errors( 

526 self, 

527 columns_cenx_err_copy: tuple[str], 

528 columns_ceny_err_copy: tuple[str], 

529 results: Table, 

530 catalog_multi: Sequence, 

531 catexps: list[CatalogExposureSourcesABC], 

532 config_data: CatalogSourceFitterConfigData, 

533 ) -> None: 

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

535 

536 This method exists to support fitting models with fixed centroids 

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

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

539 however, there is no reasonable default implementation. 

540 

541 Parameters 

542 ---------- 

543 columns_cenx_err_copy 

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

545 columns_ceny_err_copy 

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

547 results 

548 The table of fit results to copy errors into. 

549 catalog_multi 

550 The input multiband catalog. 

551 catexps 

552 The input data. 

553 config_data 

554 The fitter config and data. 

555 

556 Raises 

557 ------ 

558 NotImplementedError 

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

560 available. 

561 """ 

562 if columns_cenx_err_copy or columns_ceny_err_copy: 

563 raise NotImplementedError( 

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

565 f" but has not overriden copy_centroid_errors" 

566 ) 

567 

568 def fit( 

569 self, 

570 catalog_multi: Sequence, 

571 catexps: list[CatalogExposureSourcesABC], 

572 config_data: CatalogSourceFitterConfigData | None = None, 

573 logger: logging.Logger | None = None, 

574 **kwargs: Any, 

575 ) -> astropy.table.Table: 

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

577 

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

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

580 model parameters and a deconvolved source image from the 

581 CatalogExposureSources. 

582 

583 Parameters 

584 ---------- 

585 catalog_multi 

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

587 catexps 

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

589 config_data 

590 Configuration settings and data for fitting and output. 

591 logger 

592 The logger. Defaults to calling `_getlogger`. 

593 **kwargs 

594 Additional keyword arguments to pass to self.modeller. 

595 

596 Returns 

597 ------- 

598 catalog : `astropy.Table` 

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

600 of each source. 

601 """ 

602 if config_data is None: 

603 config_data = CatalogSourceFitterConfigData( 

604 config=CatalogSourceFitterConfig(), 

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

606 ) 

607 if logger is None: 

608 logger = self._get_logger() 

609 

610 config = config_data.config 

611 self._validate_errors_expected(config) 

612 self.validate_fit_inputs( 

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

614 ) 

615 

616 model_sources, priors = config_data.sources_priors 

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

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

619 params = config_data.parameters 

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

621 prefix = config.prefix_column 

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

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

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

625 params_cen_x: dict[str, g2f.CentroidYParameterD] = {} 

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

627 columns_err = [] 

628 

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

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

631 compute_errors: bool = errors_hessian or errors_hessian_bestfit 

632 

633 columns_cenx_err_copy = [] 

634 columns_ceny_err_copy = [] 

635 

636 suffix_err = config.suffix_error 

637 key_cen = config.get_key_cen() 

638 cen_underscored = key_cen.startswith("_") 

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

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

641 

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

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

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

645 is_cenx = isinstance(param, g2f.CentroidXParameterD) 

646 is_ceny = isinstance(param, g2f.CentroidYParameterD) 

647 

648 # Add the corresponding error key to the appropriate list 

649 if compute_errors: 

650 if param.free: 

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

652 elif is_cenx: 

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

654 elif is_ceny: 

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

656 

657 # Add this param to the appropriate dict 

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

659 param, 

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

661 ) 

662 if isinstance(param, g2f.IntegralParameterD): 

663 columns_param_flux[key_full] = param 

664 elif config.convert_cen_xy_to_radec: 

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

666 # leading underscore in case there's no prefix 

667 if is_cenx: 

668 prefix_cen, suffix_cen = ( 

669 ("", key_full) 

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

671 else key_full.split(suffix_cenx) 

672 ) 

673 params_cen_x[prefix_cen] = param 

674 elif is_ceny: 

675 prefix_cen, suffix_cen = ( 

676 ("", key_full) 

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

678 else key_full.split(suffix_ceny) 

679 ) 

680 params_cen_y[prefix_cen] = param 

681 

682 if config.convert_cen_xy_to_radec: 

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

684 columns_params_radec, columns_params_radec_err = self._get_columns_params_radec( 

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

686 compute_errors, 

687 config=config, 

688 ) 

689 

690 # Setup the results table with correct column names 

691 n_rows = len(catalog_multi) 

692 channels = self.get_channels(catexps) 

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

694 

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

696 self.copy_centroid_errors( 

697 columns_cenx_err_copy=columns_cenx_err_copy, 

698 columns_ceny_err_copy=columns_ceny_err_copy, 

699 results=results, 

700 catalog_multi=catalog_multi, 

701 catexps=catexps, 

702 config_data=config_data, 

703 ) 

704 

705 # dummy size for first iteration 

706 size, size_new = 0, 0 

707 fitInputs = FitInputsDummy() 

708 plot = False 

709 

710 # Configure default options for calls to compute_variances 

711 # keys are for values of return_negative 

712 kwargs_err_default = { 

713 True: { 

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

715 "use_diag_only": config.compute_errors_no_covar, 

716 }, 

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

718 } 

719 

720 range_idx = range(n_rows) 

721 

722 # TODO: Do this check with dummy data 

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

724 # data, psf_models = config.make_model_data( 

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

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

727 # sources=model_sources, priors=priors) 

728 # Remember to filter out fixed centroids from params 

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

730 

731 time_init_all = time.process_time() 

732 logger_periodic = PeriodicLogger(logger) 

733 n_skipfail = 0 

734 

735 for idx in range_idx: 

736 time_init = time.process_time() 

737 row = results[idx] 

738 source_multi = catalog_multi[idx] 

739 id_source = source_multi[config.column_id] 

740 row[config.column_id] = id_source 

741 time_final = time_init 

742 

743 try: 

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

745 if data.size == 0: 

746 raise NoDataError("make_model_data returned empty data") 

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

748 self.initialize_model( 

749 model, 

750 source_multi, 

751 catexps, 

752 config_data=config_data, 

753 values_init=values_init, 

754 ) 

755 

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

757 # Note: this will need to change with priors 

758 # (data should report its own size) 

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

760 if size_new != size: 

761 fitInputs = None 

762 size = size_new 

763 # Some algorithms might not even use fitInputs 

764 elif fitInputs is not None: 

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

766 

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

768 # appropriately if config.fit_linear_init is False 

769 if config.fit_linear_init: 

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

771 

772 for observation in data: 

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

774 

775 result_full = self.modeller.fit_model( 

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

777 ) 

778 fitInputs = result_full.inputs 

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

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

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

782 if config.config_fit.eval_residual: 

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

784 

785 params_free_missing = result_full.params_free_missing or tuple() 

786 

787 # Set all params to best fit values 

788 # In case the optimizer doesn't 

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

790 columns_param_free.items(), 

791 result_full.params_best, 

792 ): 

793 param.value_transformed = value 

794 if param not in params_free_missing: 

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

796 

797 # Also add any offset to the fixed parameters 

798 # (usually centroids, if any) 

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

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

801 

802 # Do a final linear fit 

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

804 if config.fit_linear_final: 

805 loglike_init, loglike_new = self.modeller.fit_model_linear( 

806 model=model, ratio_min=0.01, validate=True 

807 ) 

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

809 

810 if params_free_missing: 

811 columns_param_flux_fit = { 

812 column: param 

813 for column, param in columns_param_flux.items() 

814 if param not in params_free_missing 

815 } 

816 else: 

817 columns_param_flux_fit = columns_param_flux 

818 

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

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

821 

822 if config.convert_cen_xy_to_radec: 

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

824 # These will have been converted back if necessary 

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

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

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

828 

829 if compute_errors: 

830 errors = [] 

831 model_eval = model 

832 errors_iter = None 

833 for param in params_free_missing: 

834 param.fixed = True 

835 

836 if config.compute_errors_from_jacobian: 

837 try: 

838 errors_iter = np.sqrt( 

839 self.modeller.compute_variances( 

840 model_eval, 

841 transformed=False, 

842 use_diag_only=config.compute_errors_no_covar, 

843 ) 

844 ) 

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

846 except Exception: 

847 pass 

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

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

850 if errors_iter is None: 

851 img_data_old = [] 

852 if errors_hessian_bestfit: 

853 # Model sans prior 

854 model_eval = g2f.ModelD( 

855 data=model.data, psfmodels=model.psfmodels, sources=model.sources 

856 ) 

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

858 model_eval.evaluate() 

859 # Compute the errors by setting the data to the 

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

861 # with one iteration) 

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

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

864 img = obs.image.data 

865 img.flat = output.data.flat 

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

867 # (but would need to iterate): 

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

869 # obs.sigma_inv.data.flat) 

870 

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

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

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

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

875 # positive as negative. 

876 for return_negative in (False, True): 

877 kwargs_err = kwargs_err_default[return_negative] 

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

879 break 

880 try: 

881 errors_iter = np.sqrt( 

882 self.modeller.compute_variances( 

883 model_eval, transformed=False, **kwargs_err 

884 ) 

885 ) 

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

887 except Exception: 

888 try: 

889 errors_iter = np.sqrt( 

890 self.modeller.compute_variances( 

891 model_eval, 

892 transformed=False, 

893 use_svd=True, 

894 **kwargs_err, 

895 ) 

896 ) 

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

898 except Exception: 

899 pass 

900 # Return the data to its original noisy values 

901 # (it was replaced by the model earlier) 

902 if errors_hessian_bestfit: 

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

904 obs.image.data.flat = img_datum_old.flat 

905 # Save and optionally plot the errors 

906 if errors: 

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

908 errors = errors[idx_min][0] 

909 if plot: 

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

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

912 from ..plotting import ErrorValues, plot_loglike 

913 

914 try: 

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

916 except Exception: 

917 for param in params: 

918 param.fixed = False 

919 

920 if params_free_missing: 

921 columns_err_fitted = [ 

922 column 

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

924 if param not in params_free_missing 

925 ] 

926 else: 

927 columns_err_fitted = columns_err 

928 

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

930 results[column_err][idx] = value 

931 

932 for param in params_free_missing: 

933 param.fixed = False 

934 

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

936 if config.convert_cen_xy_to_radec: 

937 for ( 

938 key_ra_err, 

939 key_dec_err, 

940 key_cen_x, 

941 key_cen_y, 

942 key_cen_x_err, 

943 key_cen_y_err, 

944 ) in columns_params_radec_err: 

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

946 # TODO: improve this in DM-45682 

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

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

949 # Could also consider dividing by sqrt(2) 

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

951 radec_err = np.array( 

952 self.get_model_radec( 

953 source_multi, 

954 cen_x + results[key_cen_x_err][idx], 

955 cen_y + results[key_cen_y_err][idx], 

956 ) 

957 ) 

958 radec_err -= radec 

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

960 

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

962 time_final = time.process_time() 

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

964 except Exception as e: 

965 n_skipfail += 1 

966 size = 0 if fitInputs is None else size_new 

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

968 if column: 

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

970 logger.debug( 

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

972 id_source, 

973 idx, 

974 n_rows, 

975 e, 

976 ) 

977 else: 

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

979 logger.info( 

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

981 id_source, 

982 idx, 

983 n_rows, 

984 e, 

985 exc_info=1, 

986 ) 

987 logger_periodic.log( 

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

989 idx, 

990 n_rows, 

991 n_skipfail, 

992 time_final - time_init_all, 

993 ) 

994 

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

996 if n_unknown > 0: 

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

998 

999 return results 

1000 

1001 def get_channels( 

1002 self, 

1003 catexps: list[CatalogExposureSourcesABC], 

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

1005 channels = {} 

1006 for catexp in catexps: 

1007 try: 

1008 channel = catexp.channel 

1009 except AttributeError: 

1010 band = catexp.band 

1011 if callable(band): 

1012 band = band() 

1013 channel = g2f.Channel.get(band) 

1014 if channel not in channels: 

1015 channels[channel.name] = channel 

1016 return channels 

1017 

1018 def get_model( 

1019 self, 

1020 idx_row: int, 

1021 catalog_multi: Sequence, 

1022 catexps: list[CatalogExposureSourcesABC], 

1023 config_data: CatalogSourceFitterConfigData | None = None, 

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

1025 **kwargs: Any, 

1026 ) -> g2f.ModelD: 

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

1028 

1029 Parameters 

1030 ---------- 

1031 idx_row 

1032 The index of the row in the catalog. 

1033 catalog_multi 

1034 The multi-band catalog originally used for initialization. 

1035 catexps 

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

1037 config_data 

1038 The configuration used to generate sources. 

1039 Default-initialized if None. 

1040 results 

1041 The corresponding best-fit parameter catalog to initialize 

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

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

1044 **kwargs 

1045 Additional keyword arguments to pass to initialize_model. Not 

1046 used during fitting. 

1047 

1048 Returns 

1049 ------- 

1050 model 

1051 The reconstructed model. 

1052 """ 

1053 channels = self.get_channels(catexps) 

1054 if config_data is None: 

1055 config_data = CatalogSourceFitterConfigData( 

1056 config=CatalogSourceFitterConfig(), 

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

1058 ) 

1059 config = config_data.config 

1060 

1061 if not idx_row >= 0: 

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

1063 if not len(catalog_multi) > idx_row: 

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

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

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

1067 

1068 model_sources, priors = config_data.sources_priors 

1069 source_multi = catalog_multi[idx_row] 

1070 

1071 data, psf_models = config.make_model_data( 

1072 idx_row=idx_row, 

1073 catexps=catexps, 

1074 ) 

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

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

1077 

1078 if results is not None: 

1079 row = results[idx_row] 

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

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

1082 

1083 return model 

1084 

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

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

1087 

1088 Implementing this method is necessary only when fitting data with 

1089 accompanying WCS. 

1090 

1091 Parameters 

1092 ---------- 

1093 source 

1094 A mapping with fields expected to be populated in the 

1095 corresponding source catalog. 

1096 cen_x 

1097 The x-axis centroid in pixel coordinates. 

1098 cen_y 

1099 The y-axis centroid in pixel coordinates. 

1100 

1101 Returns 

1102 ------- 

1103 ra, dec 

1104 The right ascension and declination. 

1105 """ 

1106 return np.nan, np.nan 

1107 

1108 @abstractmethod 

1109 def initialize_model( 

1110 self, 

1111 model: g2f.ModelD, 

1112 source: Mapping[str, Any], 

1113 catexps: list[CatalogExposureSourcesABC], 

1114 config_data: CatalogSourceFitterConfigData, 

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

1116 **kwargs: Any, 

1117 ) -> None: 

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

1119 

1120 Parameters 

1121 ---------- 

1122 model 

1123 The model object to initialize. 

1124 source 

1125 A mapping with fields expected to be populated in the 

1126 corresponding source catalog for initialization. 

1127 catexps 

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

1129 config_data 

1130 Configuration settings and data for fitting and output. 

1131 values_init 

1132 Initial parameter values from the model configuration. 

1133 **kwargs 

1134 Additional keyword arguments that cannot be required for fitting. 

1135 """ 

1136 

1137 @abstractmethod 

1138 def validate_fit_inputs( 

1139 self, 

1140 catalog_multi: Sequence, 

1141 catexps: list[CatalogExposureSourcesABC], 

1142 config_data: CatalogSourceFitterConfigData = None, 

1143 logger: logging.Logger = None, 

1144 **kwargs: Any, 

1145 ) -> None: 

1146 """Validate inputs to self.fit. 

1147 

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

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

1150 subclass of the base classes. 

1151 

1152 Parameters 

1153 ---------- 

1154 catalog_multi 

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

1156 catexps 

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

1158 config_data 

1159 Configuration settings and data for fitting and output. 

1160 logger 

1161 The logger. Defaults to calling `_getlogger`. 

1162 **kwargs 

1163 Additional keyword arguments to pass to self.modeller. 

1164 """ 

1165 pass