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

155 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 08:43 +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 "ParameterConfig", 

24 "FluxFractionParameterConfig", 

25 "FluxParameterConfig", 

26 "CentroidConfig", 

27 "ComponentData", 

28 "Fluxes", 

29 "EllipticalComponentConfig", 

30 "GaussianComponentConfig", 

31 "SersicIndexParameterConfig", 

32 "SersicComponentConfig", 

33] 

34 

35from abc import abstractmethod 

36import string 

37from typing import Any, ClassVar 

38 

39import lsst.gauss2d.fit as g2f 

40import lsst.pex.config as pexConfig 

41import pydantic 

42 

43from .limits import limits_ref 

44from .priors import ShapePriorConfig 

45from .transforms import transforms_ref 

46from .utils import frozen_arbitrary_allowed_config 

47 

48 

49class ParameterConfig(pexConfig.Config): 

50 """Configuration for a parameter.""" 

51 

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

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

54 

55 

56class FluxParameterConfig(ParameterConfig): 

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

58 

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

60 linear fitting will not work correctly initially. 

61 """ 

62 

63 def setDefaults(self) -> None: 

64 super().setDefaults() 

65 self.value_initial = 1.0 

66 

67 

68class FluxFractionParameterConfig(ParameterConfig): 

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

70 

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

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

73 linear fitting will not work correctly initially. 

74 """ 

75 

76 def setDefaults(self) -> None: 

77 super().setDefaults() 

78 self.value_initial = 0.5 

79 

80 

81class CentroidConfig(pexConfig.Config): 

82 """Configuration for a component centroid.""" 

83 

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

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

86 

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

88 cen_x, cen_y = ( 

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

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

91 ) 

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

93 return centroid 

94 

95 

96class ComponentData(pydantic.BaseModel): 

97 """Dataclass for a Component config.""" 

98 

99 model_config: ClassVar[pydantic.ConfigDict] = frozen_arbitrary_allowed_config 

100 

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

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

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

104 

105 

106Fluxes = dict[g2f.Channel, float] 

107 

108 

109class EllipticalComponentConfig(ShapePriorConfig): 

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

111 

112 This class can be initialized but cannot implement make_component. 

113 """ 

114 

115 fluxfrac = pexConfig.ConfigField[FluxFractionParameterConfig]( 

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

117 default=None, 

118 ) 

119 flux = pexConfig.ConfigField[FluxParameterConfig]( 

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

121 default=FluxParameterConfig, 

122 ) 

123 

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

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

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

127 transform_flux_name = pexConfig.Field[str]( 

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

129 default="log10", 

130 optional=True, 

131 ) 

132 transform_fluxfrac_name = pexConfig.Field[str]( 

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

134 default="logit_fluxfrac", 

135 optional=True, 

136 ) 

137 transform_rho_name = pexConfig.Field[str]( 

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

139 default="logit_rho", 

140 optional=True, 

141 ) 

142 transform_size_name = pexConfig.Field[str]( 

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

144 default="log10", 

145 optional=True, 

146 ) 

147 

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

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

150 

151 Parameters 

152 ---------- 

153 label 

154 The label to format. 

155 name_channel 

156 The name of the channel to format with. 

157 

158 Returns 

159 ------- 

160 label_formmated 

161 The formatted label. 

162 """ 

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

164 type_component=self.get_type_name(), 

165 name_channel=name_channel, 

166 ) 

167 return label_formatted 

168 

169 @staticmethod 

170 def get_integral_label_default() -> str: 

171 """Return the default integral label.""" 

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

173 

174 @abstractmethod 

175 def get_size_label(self) -> str: 

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

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

178 

179 @abstractmethod 

180 def get_type_name(self) -> str: 

181 """Return a descriptive component name.""" 

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

183 

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

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

186 

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

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

189 

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

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

192 

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

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

195 

196 @abstractmethod 

197 def make_component( 

198 self, 

199 centroid: g2f.CentroidParameters, 

200 integral_model: g2f.IntegralModel, 

201 ) -> ComponentData: 

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

203 

204 Parameters 

205 ---------- 

206 centroid 

207 Centroid parameters for the component. 

208 integral_model 

209 The integral_model for this component. 

210 

211 Returns 

212 ------- 

213 component_data 

214 An appropriate ComponentData including the initialized component. 

215 

216 Notes 

217 ----- 

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

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

220 for linear least squares fitting. 

221 """ 

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

223 

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

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

226 

227 Returns 

228 ------- 

229 ellipse 

230 The configured ellipse. 

231 """ 

232 transform_size = self.get_transform_size() 

233 transform_rho = self.get_transform_rho() 

234 ellipse = g2f.GaussianParametricEllipse( 

235 sigma_x=g2f.SigmaXParameterD( 

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

237 ), 

238 sigma_y=g2f.SigmaYParameterD( 

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

240 ), 

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

242 ) 

243 return ellipse 

244 

245 def make_fluxfrac_parameter( 

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

247 ) -> g2f.ProperFractionParameterD: 

248 parameter = g2f.ProperFractionParameterD( 

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

250 fixed=self.fluxfrac.fixed, 

251 transform=self.get_transform_fluxfrac(), 

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

253 **kwargs, 

254 ) 

255 return parameter 

256 

257 def make_flux_parameter( 

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

259 ) -> g2f.IntegralParameterD: 

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

261 

262 Parameters 

263 ---------- 

264 value 

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

266 label 

267 The label for the parameter. Default empty string. 

268 **kwargs 

269 Other keyword arguments to pass to the IntegralParameterD 

270 constructor. 

271 

272 Returns 

273 ------- 

274 param 

275 The constructed IntegralParameterD. 

276 """ 

277 parameter = g2f.IntegralParameterD( 

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

279 fixed=self.flux.fixed, 

280 transform=self.get_transform_flux(), 

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

282 **kwargs, 

283 ) 

284 return parameter 

285 

286 def make_linear_integral_model( 

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

288 ) -> g2f.IntegralModel: 

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

290 

291 Parameters 

292 ---------- 

293 fluxes 

294 Configurations, including initial values, for the flux 

295 parameters by channel. 

296 label_integral 

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

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

299 **kwargs 

300 Additional keyword arguments to pass to make_flux_parameter. 

301 Some parameters cannot be overriden from their configs. 

302 

303 Returns 

304 ------- 

305 integral_model 

306 The requested lsst.gauss2d.fit.IntegralModel. 

307 """ 

308 if label_integral is None: 

309 label_integral = self.get_integral_label_default() 

310 integral_model = g2f.LinearIntegralModel( 

311 [ 

312 ( 

313 channel, 

314 self.make_flux_parameter( 

315 flux, 

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

317 **kwargs, 

318 ), 

319 ) 

320 for channel, flux in fluxes.items() 

321 ] 

322 ) 

323 return integral_model 

324 

325 @staticmethod 

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

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

328 

329 Parameters 

330 ---------- 

331 component 

332 The component to set the size for. 

333 size_x 

334 The value to set. 

335 """ 

336 component.ellipse.sigma_x = size_x 

337 

338 @staticmethod 

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

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

341 

342 Parameters 

343 ---------- 

344 component 

345 The component to set the size for. 

346 size_y 

347 The value to set. 

348 """ 

349 component.ellipse.sigma_y = size_y 

350 

351 @staticmethod 

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

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

354 

355 Parameters 

356 ---------- 

357 component 

358 The component to set the size for. 

359 rho 

360 The value to set. 

361 """ 

362 component.ellipse.rho = rho 

363 

364 

365class GaussianComponentConfig(EllipticalComponentConfig): 

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

367 

368 _size_label = "sigma" 

369 

370 transform_frac_name = pexConfig.Field[str]( 

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

372 default="log10", 

373 optional=True, 

374 ) 

375 

376 def get_size_label(self) -> str: 

377 return self._size_label 

378 

379 def get_type_name(self) -> str: 

380 return "Gaussian" 

381 

382 def make_component( 

383 self, 

384 centroid: g2f.CentroidParameters, 

385 integral_model: g2f.IntegralModel, 

386 ) -> ComponentData: 

387 ellipse = self.make_gaussianparametricellipse() 

388 prior = self.make_shape_prior(ellipse) 

389 component_data = ComponentData( 

390 component=g2f.GaussianComponent( 

391 centroid=centroid, 

392 ellipse=ellipse, 

393 integral=integral_model, 

394 ), 

395 integral_model=integral_model, 

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

397 ) 

398 return component_data 

399 

400 

401class SersicIndexParameterConfig(ParameterConfig): 

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

403 

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

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

406 prior_transformed = pexConfig.Field[float]( 

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

408 default=True, 

409 ) 

410 

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

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

413 

414 Parameters 

415 ---------- 

416 param 

417 The parameter to make a prior for. 

418 

419 Returns 

420 ------- 

421 prior 

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

423 the required config parameters are None. 

424 """ 

425 if self.prior_mean is not None: 

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

427 stddev = ( 

428 ( 

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

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

431 ) 

432 if self.prior_transformed 

433 else self.prior_stddev 

434 ) 

435 return g2f.GaussianPrior( 

436 param=param, 

437 mean=mean, 

438 stddev=stddev, 

439 transformed=self.prior_transformed, 

440 ) 

441 return None 

442 

443 def setDefaults(self) -> None: 

444 self.value_initial = 0.5 

445 

446 def validate(self) -> None: 

447 super().validate() 

448 if self.prior_mean is not None: 

449 if not self.prior_mean > 0.0: 

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

451 if not self.prior_stddev > 0.0: 

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

453 

454 

455class SersicComponentConfig(EllipticalComponentConfig): 

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

457 

458 Notes 

459 ----- 

460 make_component will return a `ComponentData` with an 

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

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

463 """ 

464 

465 _interpolator_class_default = ( 

466 g2f.GSLSersicMixInterpolator 

467 if hasattr(g2f, "GSLSersicMixInterpolator") 

468 else g2f.LinearSersicMixInterpolator 

469 ) 

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

471 4: _interpolator_class_default(4), 

472 8: _interpolator_class_default(8), 

473 } 

474 _size_label = "reff" 

475 

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

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

478 

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

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

481 

482 Parameters 

483 ---------- 

484 order 

485 The order of the desired interpolator. 

486 

487 Returns 

488 ------- 

489 interpolator 

490 An interpolator of the requested order. 

491 """ 

492 return self._interpolators.get( 

493 order, 

494 ( 

495 g2f.GSLSersicMixInterpolator 

496 if hasattr(g2f, "GSLSersicMixInterpolator") 

497 else g2f.LinearSersicMixInterpolator 

498 )(order=order), 

499 ) 

500 

501 def get_size_label(self) -> str: 

502 return self._size_label 

503 

504 def get_type_name(self) -> str: 

505 is_gaussian_fixed = self.is_gaussian_fixed() 

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

507 

508 def is_gaussian_fixed(self) -> bool: 

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

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

511 

512 def make_component( 

513 self, 

514 centroid: g2f.CentroidParameters, 

515 integral_model: g2f.IntegralModel, 

516 ) -> ComponentData: 

517 is_gaussian_fixed = self.is_gaussian_fixed() 

518 transform_size = self.get_transform_size() 

519 transform_rho = self.get_transform_rho() 

520 if is_gaussian_fixed: 

521 ellipse = self.make_gaussianparametricellipse() 

522 component = g2f.GaussianComponent( 

523 centroid=centroid, 

524 ellipse=ellipse, 

525 integral=integral_model, 

526 ) 

527 priors = [] 

528 else: 

529 ellipse = g2f.SersicParametricEllipse( 

530 size_x=g2f.ReffXParameterD( 

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

532 ), 

533 size_y=g2f.ReffYParameterD( 

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

535 ), 

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

537 ) 

538 sersic_index = g2f.SersicMixComponentIndexParameterD( 

539 value=self.sersic_index.value_initial, 

540 fixed=self.sersic_index.fixed, 

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

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

543 limits=limits_ref["n_ser_multigauss"], 

544 ) 

545 component = g2f.SersicMixComponent( 

546 centroid=centroid, 

547 ellipse=ellipse, 

548 integral=integral_model, 

549 sersicindex=sersic_index, 

550 ) 

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

552 priors = [prior] if prior else [] 

553 prior = self.make_shape_prior(ellipse) 

554 if prior: 

555 priors.append(prior) 

556 return ComponentData( 

557 component=component, 

558 integral_model=integral_model, 

559 priors=priors, 

560 ) 

561 

562 def validate(self) -> None: 

563 super().validate()