Coverage for python/lsst/multiprofit/componentconfig.py: 95%

155 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-14 09:23 +0000

1# This file is part of multiprofit. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22__all__ = [ 

23 "CentroidConfig", 

24 "ComponentData", 

25 "EllipticalComponentConfig", 

26 "FluxFractionParameterConfig", 

27 "FluxParameterConfig", 

28 "Fluxes", 

29 "GaussianComponentConfig", 

30 "ParameterConfig", 

31 "SersicComponentConfig", 

32 "SersicIndexParameterConfig", 

33] 

34 

35import string 

36from abc import abstractmethod 

37from typing import Any, ClassVar 

38 

39import pydantic 

40 

41import lsst.gauss2d.fit as g2f 

42import lsst.pex.config as pexConfig 

43 

44from .limits import limits_ref 

45from .priors import ShapePriorConfig 

46from .transforms import transforms_ref 

47from .utils import frozen_arbitrary_allowed_config 

48 

49 

50class ParameterConfig(pexConfig.Config): 

51 """Configuration for a parameter.""" 

52 

53 fixed = pexConfig.Field[bool](default=False, doc="Whether parameter is fixed or not (free)") 

54 value_initial = pexConfig.Field[float](default=0, doc="Initial value") 

55 

56 

57class FluxParameterConfig(ParameterConfig): 

58 """Configuration for flux parameters (IntegralParameterD). 

59 

60 The safest initial value for a flux is 1.0, because if it's set to zero, 

61 linear fitting will not work correctly initially. 

62 """ 

63 

64 def setDefaults(self) -> None: 

65 super().setDefaults() 

66 self.value_initial = 1.0 

67 

68 

69class FluxFractionParameterConfig(ParameterConfig): 

70 """Configuration for flux fraction parameters (ProperFractionParameterD). 

71 

72 The safest initial value for a flux fraction is 0.5, because if it's set 

73 to one, downstream fractions will be zero, while if it's set to zero, 

74 linear fitting will not work correctly initially. 

75 """ 

76 

77 def setDefaults(self) -> None: 

78 super().setDefaults() 

79 self.value_initial = 0.5 

80 

81 

82class CentroidConfig(pexConfig.Config): 

83 """Configuration for a component centroid.""" 

84 

85 x = pexConfig.ConfigField[ParameterConfig](doc="The x-axis centroid configuration") 

86 y = pexConfig.ConfigField[ParameterConfig](doc="The y-axis centroid configuration") 

87 

88 def make_centroid(self) -> g2f.CentroidParameters: 

89 cen_x, cen_y = ( 

90 type_param(config.value_initial, fixed=config.fixed, limits=g2f.LimitsD()) 

91 for (config, type_param) in ((self.x, g2f.CentroidXParameterD), (self.y, g2f.CentroidYParameterD)) 

92 ) 

93 centroid = g2f.CentroidParameters(x=cen_x, y=cen_y) 

94 return centroid 

95 

96 

97class ComponentData(pydantic.BaseModel): 

98 """Dataclass for a Component config.""" 

99 

100 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

101 

102 component: g2f.Component = pydantic.Field(title="The component instance") 

103 integral_model: g2f.IntegralModel = pydantic.Field(title="The component's integral_model") 

104 priors: list[g2f.Prior] = pydantic.Field(title="The priors associated with the component") 

105 

106 

107Fluxes = dict[g2f.Channel, float] 

108 

109 

110class EllipticalComponentConfig(ShapePriorConfig): 

111 """Configuration for an elliptically-symmetric component. 

112 

113 This class can be initialized but cannot implement make_component. 

114 """ 

115 

116 fluxfrac = pexConfig.ConfigField[FluxFractionParameterConfig]( 

117 doc="Fractional flux parameter(s) config", 

118 default=None, 

119 ) 

120 flux = pexConfig.ConfigField[FluxParameterConfig]( 

121 doc="Flux parameter(s) config", 

122 default=FluxParameterConfig, 

123 ) 

124 

125 rho = pexConfig.ConfigField[ParameterConfig](doc="Rho parameter config") 

126 size_x = pexConfig.ConfigField[ParameterConfig](doc="x-axis size parameter config") 

127 size_y = pexConfig.ConfigField[ParameterConfig](doc="y-axis size parameter config") 

128 transform_flux_name = pexConfig.Field[str]( 

129 doc="The name of the reference transform for flux parameters", 

130 default="log10", 

131 optional=True, 

132 ) 

133 transform_fluxfrac_name = pexConfig.Field[str]( 

134 doc="The name of the reference transform for flux fraction parameters", 

135 default="logit_fluxfrac", 

136 optional=True, 

137 ) 

138 transform_rho_name = pexConfig.Field[str]( 

139 doc="The name of the reference transform for rho parameters", 

140 default="logit_rho", 

141 optional=True, 

142 ) 

143 transform_size_name = pexConfig.Field[str]( 

144 doc="The name of the reference transform for size parameters", 

145 default="log10", 

146 optional=True, 

147 ) 

148 

149 def format_label(self, label: str, name_channel: str) -> str: 

150 """Format a label for a band-dependent parameter. 

151 

152 Parameters 

153 ---------- 

154 label 

155 The label to format. 

156 name_channel 

157 The name of the channel to format with. 

158 

159 Returns 

160 ------- 

161 label_formmated 

162 The formatted label. 

163 """ 

164 label_formatted = string.Template(label).safe_substitute( 

165 type_component=self.get_type_name(), 

166 name_channel=name_channel, 

167 ) 

168 return label_formatted 

169 

170 @staticmethod 

171 def get_integral_label_default() -> str: 

172 """Return the default integral label.""" 

173 return "${type_component} ${name_channel}-band" 

174 

175 @abstractmethod 

176 def get_size_label(self) -> str: 

177 """Return the label for the component's size parameters.""" 

178 raise NotImplementedError("EllipticalComponent does not implement get_size_label") 

179 

180 @abstractmethod 

181 def get_type_name(self) -> str: 

182 """Return a descriptive component name.""" 

183 raise NotImplementedError("EllipticalComponent does not implement get_type_name") 

184 

185 def get_transform_fluxfrac(self) -> g2f.TransformD | None: 

186 return transforms_ref[self.transform_fluxfrac_name] if self.transform_fluxfrac_name else None 

187 

188 def get_transform_flux(self) -> g2f.TransformD | None: 

189 return transforms_ref[self.transform_flux_name] if self.transform_flux_name else None 

190 

191 def get_transform_rho(self) -> g2f.TransformD | None: 

192 return transforms_ref[self.transform_rho_name] if self.transform_rho_name else None 

193 

194 def get_transform_size(self) -> g2f.TransformD | None: 

195 return transforms_ref[self.transform_size_name] if self.transform_size_name else None 

196 

197 @abstractmethod 

198 def make_component( 

199 self, 

200 centroid: g2f.CentroidParameters, 

201 integral_model: g2f.IntegralModel, 

202 ) -> ComponentData: 

203 """Make a Component reflecting the current configuration. 

204 

205 Parameters 

206 ---------- 

207 centroid 

208 Centroid parameters for the component. 

209 integral_model 

210 The integral_model for this component. 

211 

212 Returns 

213 ------- 

214 component_data 

215 An appropriate ComponentData including the initialized component. 

216 

217 Notes 

218 ----- 

219 The default `gauss2d.fit.LinearIntegralModel` can be populated with 

220 unit fluxes (`gauss2d.fit.IntegralParameterD` instances) to prepare 

221 for linear least squares fitting. 

222 """ 

223 raise NotImplementedError("EllipticalComponent cannot not implement make_component") 

224 

225 def make_gaussianparametricellipse(self) -> g2f.GaussianParametricEllipse: 

226 """Make a GaussianParametericEllipse from this object's configuration. 

227 

228 Returns 

229 ------- 

230 ellipse 

231 The configured ellipse. 

232 """ 

233 transform_size = self.get_transform_size() 

234 transform_rho = self.get_transform_rho() 

235 ellipse = g2f.GaussianParametricEllipse( 

236 sigma_x=g2f.SigmaXParameterD( 

237 self.size_x.value_initial, transform=transform_size, fixed=self.size_x.fixed 

238 ), 

239 sigma_y=g2f.SigmaYParameterD( 

240 self.size_y.value_initial, transform=transform_size, fixed=self.size_y.fixed 

241 ), 

242 rho=g2f.RhoParameterD(self.rho.value_initial, transform=transform_rho, fixed=self.rho.fixed), 

243 ) 

244 return ellipse 

245 

246 def make_fluxfrac_parameter( 

247 self, value: float | None, label: str | None = None, **kwargs: Any 

248 ) -> g2f.ProperFractionParameterD: 

249 parameter = g2f.ProperFractionParameterD( 

250 value if value is None else self.fluxfrac.value_initial, 

251 fixed=self.fluxfrac.fixed, 

252 transform=self.get_transform_fluxfrac(), 

253 label=label if label is not None else "", 

254 **kwargs, 

255 ) 

256 return parameter 

257 

258 def make_flux_parameter( 

259 self, value: float | None, label: str | None = None, **kwargs: Any 

260 ) -> g2f.IntegralParameterD: 

261 """Make a single IntegralParameterD from this object's configuration. 

262 

263 Parameters 

264 ---------- 

265 value 

266 The initial value. Default is self.flux.value_initial. 

267 label 

268 The label for the parameter. Default empty string. 

269 **kwargs 

270 Other keyword arguments to pass to the IntegralParameterD 

271 constructor. 

272 

273 Returns 

274 ------- 

275 param 

276 The constructed IntegralParameterD. 

277 """ 

278 parameter = g2f.IntegralParameterD( 

279 value if value is not None else self.flux.value_initial, 

280 fixed=self.flux.fixed, 

281 transform=self.get_transform_flux(), 

282 label=label if label is not None else "", 

283 **kwargs, 

284 ) 

285 return parameter 

286 

287 def make_linear_integral_model( 

288 self, fluxes: Fluxes, label_integral: str | None = None, **kwargs: Any 

289 ) -> g2f.IntegralModel: 

290 """Make an lsst.gauss2d.fit.LinearIntegralModel for this component. 

291 

292 Parameters 

293 ---------- 

294 fluxes 

295 Configurations, including initial values, for the flux 

296 parameters by channel. 

297 label_integral 

298 A label to apply to integral parameters. Can reference the 

299 relevant channel with e.g. {channel.name}. 

300 **kwargs 

301 Additional keyword arguments to pass to make_flux_parameter. 

302 Some parameters cannot be overriden from their configs. 

303 

304 Returns 

305 ------- 

306 integral_model 

307 The requested lsst.gauss2d.fit.IntegralModel. 

308 """ 

309 if label_integral is None: 

310 label_integral = self.get_integral_label_default() 

311 integral_model = g2f.LinearIntegralModel( 

312 [ 

313 ( 

314 channel, 

315 self.make_flux_parameter( 

316 flux, 

317 label=self.format_label(label_integral, name_channel=channel.name), 

318 **kwargs, 

319 ), 

320 ) 

321 for channel, flux in fluxes.items() 

322 ] 

323 ) 

324 return integral_model 

325 

326 @staticmethod 

327 def set_size_x(component: g2f.EllipticalComponent, size_x: float) -> None: 

328 """Set the x-axis size parameter value for a component. 

329 

330 Parameters 

331 ---------- 

332 component 

333 The component to set the size for. 

334 size_x 

335 The value to set. 

336 """ 

337 component.ellipse.sigma_x = size_x 

338 

339 @staticmethod 

340 def set_size_y(component: g2f.EllipticalComponent, size_y: float) -> None: 

341 """Set the y-axis size parameter value for a component. 

342 

343 Parameters 

344 ---------- 

345 component 

346 The component to set the size for. 

347 size_y 

348 The value to set. 

349 """ 

350 component.ellipse.sigma_y = size_y 

351 

352 @staticmethod 

353 def set_rho(component: g2f.EllipticalComponent, rho: float) -> None: 

354 """Set the rho parameter value for a component. 

355 

356 Parameters 

357 ---------- 

358 component 

359 The component to set the size for. 

360 rho 

361 The value to set. 

362 """ 

363 component.ellipse.rho = rho 

364 

365 

366class GaussianComponentConfig(EllipticalComponentConfig): 

367 """Configuration for an lsst.gauss2d.fit Gaussian component.""" 

368 

369 _size_label = "sigma" 

370 

371 transform_frac_name = pexConfig.Field[str]( 

372 doc="The name of the reference transform for flux fraction parameters", 

373 default="log10", 

374 optional=True, 

375 ) 

376 

377 def get_size_label(self) -> str: 

378 return self._size_label 

379 

380 def get_type_name(self) -> str: 

381 return "Gaussian" 

382 

383 def make_component( 

384 self, 

385 centroid: g2f.CentroidParameters, 

386 integral_model: g2f.IntegralModel, 

387 ) -> ComponentData: 

388 ellipse = self.make_gaussianparametricellipse() 

389 prior = self.make_shape_prior(ellipse) 

390 component_data = ComponentData( 

391 component=g2f.GaussianComponent( 

392 centroid=centroid, 

393 ellipse=ellipse, 

394 integral=integral_model, 

395 ), 

396 integral_model=integral_model, 

397 priors=[] if prior is None else [prior], 

398 ) 

399 return component_data 

400 

401 

402class SersicIndexParameterConfig(ParameterConfig): 

403 """Configuration for an lsst.gauss2d.fit Sersic index parameter.""" 

404 

405 prior_mean = pexConfig.Field[float](doc="Mean for the prior (untransformed)", default=1.0, optional=True) 

406 prior_stddev = pexConfig.Field[float](doc="Std. dev. for the prior", default=0.5, optional=True) 

407 prior_transformed = pexConfig.Field[float]( 

408 doc="Whether the prior should be in transformed values", 

409 default=True, 

410 ) 

411 

412 def make_prior(self, param: g2f.SersicIndexParameterD) -> g2f.Prior | None: 

413 """Make a Gaussian prior for a given SersicIndexParameterD. 

414 

415 Parameters 

416 ---------- 

417 param 

418 The parameter to make a prior for. 

419 

420 Returns 

421 ------- 

422 prior 

423 The prior object, set according to the configuration, or none if 

424 the required config parameters are None. 

425 """ 

426 if self.prior_mean is not None: 

427 mean = param.transform.forward(self.prior_mean) if self.prior_transformed else self.prior_mean 

428 stddev = ( 

429 ( 

430 param.transform.forward(self.prior_mean + self.prior_stddev / 2.0) 

431 - param.transform.forward(self.prior_mean - self.prior_stddev / 2.0) 

432 ) 

433 if self.prior_transformed 

434 else self.prior_stddev 

435 ) 

436 return g2f.GaussianPrior( 

437 param=param, 

438 mean=mean, 

439 stddev=stddev, 

440 transformed=self.prior_transformed, 

441 ) 

442 return None 

443 

444 def setDefaults(self) -> None: 

445 self.value_initial = 0.5 

446 

447 def validate(self) -> None: 

448 super().validate() 

449 if self.prior_mean is not None: 449 ↛ exitline 449 didn't return from function 'validate' because the condition on line 449 was always true

450 if not self.prior_mean > 0.0: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true

451 raise ValueError("Sersic index prior mean must be > 0") 

452 if not self.prior_stddev > 0.0: 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 raise ValueError("Sersic index prior std. dev. must be > 0") 

454 

455 

456class SersicComponentConfig(EllipticalComponentConfig): 

457 """Configuration for an lsst.gauss2d.fit Sersic component. 

458 

459 Notes 

460 ----- 

461 make_component will return a `ComponentData` with an 

462 `lsst.gauss2d.fit.GaussianComponent` if the Sersic index is fixed at 0.5, 

463 or an `lsst.gauss2d.fit.SersicMixComponent` otherwise. 

464 """ 

465 

466 _interpolator_class_default = ( 

467 g2f.GSLSersicMixInterpolator 

468 if hasattr(g2f, "GSLSersicMixInterpolator") 

469 else g2f.LinearSersicMixInterpolator 

470 ) 

471 _interpolators: dict[int, g2f.SersicMixInterpolator] = { 

472 4: _interpolator_class_default(4), 

473 8: _interpolator_class_default(8), 

474 } 

475 _size_label = "reff" 

476 

477 order = pexConfig.ChoiceField[int](doc="Sersic mix order", allowed={4: "Four", 8: "Eight"}, default=4) 

478 sersic_index = pexConfig.ConfigField[SersicIndexParameterConfig](doc="Sersic index config") 

479 

480 def get_interpolator(self, order: int) -> g2f.SersicMixInterpolator: 

481 """Get the best available interpolator for a given order. 

482 

483 Parameters 

484 ---------- 

485 order 

486 The order of the desired interpolator. 

487 

488 Returns 

489 ------- 

490 interpolator 

491 An interpolator of the requested order. 

492 """ 

493 return self._interpolators.get( 

494 order, 

495 ( 

496 g2f.GSLSersicMixInterpolator 

497 if hasattr(g2f, "GSLSersicMixInterpolator") 

498 else g2f.LinearSersicMixInterpolator 

499 )(order=order), 

500 ) 

501 

502 def get_size_label(self) -> str: 

503 return self._size_label 

504 

505 def get_type_name(self) -> str: 

506 is_gaussian_fixed = self.is_gaussian_fixed() 

507 return f"{'Gaussian (fixed Sersic)' if is_gaussian_fixed else 'Sersic'}" 

508 

509 def is_gaussian_fixed(self) -> bool: 

510 """Return True if the Sersic index is fixed at 0.5.""" 

511 return self.sersic_index.value_initial == 0.5 and self.sersic_index.fixed 

512 

513 def make_component( 

514 self, 

515 centroid: g2f.CentroidParameters, 

516 integral_model: g2f.IntegralModel, 

517 ) -> ComponentData: 

518 is_gaussian_fixed = self.is_gaussian_fixed() 

519 transform_size = self.get_transform_size() 

520 transform_rho = self.get_transform_rho() 

521 if is_gaussian_fixed: 

522 ellipse = self.make_gaussianparametricellipse() 

523 component = g2f.GaussianComponent( 

524 centroid=centroid, 

525 ellipse=ellipse, 

526 integral=integral_model, 

527 ) 

528 priors = [] 

529 else: 

530 ellipse = g2f.SersicParametricEllipse( 

531 size_x=g2f.ReffXParameterD( 

532 self.size_x.value_initial, transform=transform_size, fixed=self.size_x.fixed 

533 ), 

534 size_y=g2f.ReffYParameterD( 

535 self.size_y.value_initial, transform=transform_size, fixed=self.size_y.fixed 

536 ), 

537 rho=g2f.RhoParameterD(self.rho.value_initial, transform=transform_rho, fixed=self.rho.fixed), 

538 ) 

539 sersic_index = g2f.SersicMixComponentIndexParameterD( 

540 value=self.sersic_index.value_initial, 

541 fixed=self.sersic_index.fixed, 

542 transform=transforms_ref["logit_sersic"] if not self.sersic_index.fixed else None, 

543 interpolator=self.get_interpolator(order=self.order), 

544 limits=limits_ref["n_ser_multigauss"], 

545 ) 

546 component = g2f.SersicMixComponent( 

547 centroid=centroid, 

548 ellipse=ellipse, 

549 integral=integral_model, 

550 sersicindex=sersic_index, 

551 ) 

552 prior = self.sersic_index.make_prior(sersic_index) if not sersic_index.fixed else None 

553 priors = [prior] if prior else [] 

554 prior = self.make_shape_prior(ellipse) 

555 if prior: 

556 priors.append(prior) 

557 return ComponentData( 

558 component=component, 

559 integral_model=integral_model, 

560 priors=priors, 

561 ) 

562 

563 def validate(self) -> None: 

564 super().validate()