Coverage for python/lsst/meas/extensions/gaap/_gaap.py: 27%

280 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-15 09:26 +0000

1# This file is part of meas_extensions_gaap 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 LSST License Statement and 

20# the GNU General Public License along with this program. If not, 

21# see <http://www.lsstcorp.org/LegalNotices/>. 

22 

23from __future__ import annotations 

24 

25__all__ = ("SingleFrameGaapFluxPlugin", "SingleFrameGaapFluxConfig", 

26 "ForcedGaapFluxPlugin", "ForcedGaapFluxConfig") 

27 

28from typing import Generator, Optional, Union 

29from functools import partial 

30import itertools 

31import logging 

32import lsst.afw.detection as afwDetection 

33import lsst.afw.image as afwImage 

34import lsst.afw.geom as afwGeom 

35import lsst.afw.table as afwTable 

36import lsst.geom 

37import lsst.meas.base as measBase 

38import lsst.pex.config as pexConfig 

39from lsst.pex.exceptions import InvalidParameterError 

40import scipy.signal 

41from ._gaussianizePsf import GaussianizePsfTask 

42 

43PLUGIN_NAME = "ext_gaap_GaapFlux" 

44 

45 

46class GaapConvolutionError(measBase.MeasurementError): 

47 """Raised when there is an error in GAaP convolution. 

48 """ 

49 

50 

51class NoPixelError(measBase.MeasurementError): 

52 """Raised when the footprint has no pixels. 

53 """ 

54 

55 

56class BaseGaapFluxConfig(measBase.BaseMeasurementPluginConfig): 

57 """Configuration parameters for Gaussian Aperture and PSF (GAaP) plugin. 

58 """ 

59 def _greaterThanOrEqualToUnity(x: float) -> bool: # noqa: N805 

60 """Returns True if the input ``x`` is greater than 1.0, else False. 

61 """ 

62 return x >= 1 

63 

64 def _isOdd(x: int) -> bool: # noqa: N805 

65 """Returns True if the input ``x`` is positive and odd, else False. 

66 """ 

67 return (x%2 == 1) & (x > 0) 

68 

69 sigmas = pexConfig.ListField( 

70 dtype=float, 

71 default=[0.7, 1.0], 

72 doc="List of sigmas (in arcseconds) of circular Gaussian apertures to apply on " 

73 "pre-seeing galaxy images. These should be somewhat larger than the PSF " 

74 "(determined by ``scalingFactors``) to avoid measurement failures." 

75 ) 

76 

77 scalingFactors = pexConfig.ListField( 

78 dtype=float, 

79 default=[1.15], 

80 itemCheck=_greaterThanOrEqualToUnity, 

81 doc="List of factors with which the seeing should be scaled to obtain the " 

82 "sigma values of the target Gaussian PSF. The factor should not be less " 

83 "than unity to avoid the PSF matching task to go into deconvolution mode " 

84 "and should ideally be slightly greater than unity. The runtime of the " 

85 "plugin scales linearly with the number of elements in the list." 

86 ) 

87 

88 _modelPsfMatch = pexConfig.ConfigurableField( 

89 target=GaussianizePsfTask, 

90 doc="PSF Gaussianization Task" 

91 ) 

92 

93 _modelPsfDimension = pexConfig.Field( 

94 dtype=int, 

95 default=65, 

96 check=_isOdd, 

97 doc="The dimensions (width and height) of the target PSF image in pixels. Must be odd." 

98 ) 

99 

100 doPsfPhotometry = pexConfig.Field( 

101 dtype=bool, 

102 default=False, 

103 doc="Perform PSF photometry after PSF-Gaussianization to validate Gaussianization accuracy? " 

104 "This does not produce consistent color estimates. If setting it to `True`, it must be done so " 

105 "prior to registering the plugin for aperture correction if ``registerForApCorr`` is also `True`." 

106 ) 

107 

108 doOptimalPhotometry = pexConfig.Field( 

109 dtype=bool, 

110 default=True, 

111 doc="Perform optimal photometry with near maximal SNR using an adaptive elliptical aperture? " 

112 "This requires a shape algorithm to have been run previously." 

113 ) 

114 

115 registerForApCorr = pexConfig.Field( 

116 dtype=bool, 

117 default=True, 

118 doc="Register measurements for aperture correction? " 

119 "The aperture correction registration is done when the plugin is instatiated and not " 

120 "during import because the column names are derived from the configuration rather than being " 

121 "static. Sometimes you want to turn this off, e.g., when you use aperture corrections derived " 

122 "from somewhere else through a 'proxy' mechanism." 

123 ) 

124 

125 # scaleByFwm is the only config field of modelPsfMatch Task that we allow 

126 # the user to set without explicitly setting the modelPsfMatch config. 

127 # It is intended to abstract away the underlying implementation. 

128 @property 

129 def scaleByFwhm(self) -> bool: 

130 """Config parameter of the PSF Matching task. 

131 Scale kernelSize, alardGaussians by input Fwhm? 

132 """ 

133 return self._modelPsfMatch.kernel.active.scaleByFwhm 

134 

135 @scaleByFwhm.setter 

136 def scaleByFwhm(self, value: bool) -> None: 

137 self._modelPsfMatch.kernel.active.scaleByFwhm = value 

138 

139 @property 

140 def gaussianizationMethod(self) -> str: 

141 """Type of convolution to use for PSF-Gaussianization.""" 

142 return self._modelPsfMatch.convolutionMethod 

143 

144 @gaussianizationMethod.setter 

145 def gaussianizationMethod(self, value: str) -> None: 

146 self._modelPsfMatch.convolutionMethod = value 

147 

148 @property 

149 def _sigmas(self) -> list: 

150 """List of values set in ``sigmas`` along with special apertures such 

151 as "PsfFlux" and "Optimal" if applicable. 

152 """ 

153 return self.sigmas.list() + ["PsfFlux"]*self.doPsfPhotometry + ["Optimal"]*self.doOptimalPhotometry 

154 

155 def setDefaults(self) -> None: 

156 # Docstring inherited 

157 self._modelPsfMatch.kernel.active.alardNGauss = 1 

158 self._modelPsfMatch.kernel.active.alardDegGaussDeconv = 1 

159 self._modelPsfMatch.kernel.active.alardDegGauss = [4] 

160 self._modelPsfMatch.kernel.active.alardGaussBeta = 1.0 

161 self._modelPsfMatch.kernel.active.spatialKernelOrder = 0 

162 self.scaleByFwhm = True 

163 

164 def validate(self): 

165 super().validate() 

166 self._modelPsfMatch.validate() 

167 assert self._modelPsfMatch.kernel.active.alardNGauss == 1 

168 

169 @staticmethod 

170 def _getGaapResultName(scalingFactor: float, sigma: Union[float, str], name: Optional[str] = None) -> str: 

171 """Return the base name for GAaP fields 

172 

173 For example, for a scaling factor of 1.15 for seeing and sigma of the 

174 effective Gaussian aperture of 0.7 arcsec, the returned value would be 

175 "ext_gaap_GaapFlux_1_15x_0_7". 

176 

177 Notes 

178 ----- 

179 Being a static method, this does not check if measurements correspond 

180 to the input arguments. Instead, users should use 

181 `getAllGaapResultNames` to obtain the full list of base names. 

182 

183 This is not a config-y thing, but is placed here to make the fieldnames 

184 from GAaP measurements available outside the plugin. 

185 

186 Parameters 

187 ---------- 

188 scalingFactor : `float` 

189 The factor by which the trace radius of the PSF must be scaled. 

190 sigma : `float` or `str` 

191 Sigma of the effective Gaussian aperture (PSF-convolved explicit 

192 aperture) or "PsfFlux" for PSF photometry post PSF-Gaussianization. 

193 name : `str`, optional 

194 The exact registered name of the GAaP plugin, typically either 

195 "ext_gaap_GaapFlux" or "undeblended_ext_gaap_GaapFlux". If ``name`` 

196 is None, then only the middle part (1_15x_0_7 in the example) 

197 without the leading underscore is returned. 

198 

199 Returns 

200 ------- 

201 baseName : `str` 

202 Base name for GAaP field. 

203 """ 

204 suffix = "_".join((str(scalingFactor).replace(".", "_")+"x", str(sigma).replace(".", "_"))) 

205 if name is None: 

206 return suffix 

207 return "_".join((name, suffix)) 

208 

209 def getAllGaapResultNames(self, name: Optional[str] = PLUGIN_NAME) -> Generator[str]: 

210 """Generate the base names for all of the GAaP fields. 

211 

212 For example, if the plugin is configured with `scalingFactors` = [1.15] 

213 and `sigmas` = [0.7, 1.0] the returned expression would yield 

214 ("ext_gaap_GaapFlux_1_15x_0_7", "ext_gaap_GaapFlux_1_15x_1_0") when 

215 called with ``name`` = "ext_gaap_GaapFlux". It will also generate 

216 "ext_gaap_GaapFlux_1_15x_PsfFlux" if `doPsfPhotometry` is True. 

217 

218 Parameters 

219 ---------- 

220 name : `str`, optional 

221 The exact registered name of the GAaP plugin, typically either 

222 "ext_gaap_GaapFlux" or "undeblended_ext_gaap_GaapFlux". If ``name`` 

223 is None, then only the middle parts (("1_15x_0_7", "1_15x_1_0"), 

224 for example) without the leading underscores are returned. 

225 

226 Returns 

227 ------- 

228 baseNames : `generator` 

229 A generator expression yielding all the base names. 

230 """ 

231 scalingFactors = self.scalingFactors 

232 sigmas = self._sigmas 

233 baseNames = (self._getGaapResultName(scalingFactor, sigma, name) 

234 for scalingFactor, sigma in itertools.product(scalingFactors, sigmas)) 

235 return baseNames 

236 

237 

238class BaseGaapFluxMixin: 

239 """Mixin base class for Gaussian-Aperture and PSF (GAaP) photometry 

240 algorithm. 

241 

242 This class does almost all the heavy-lifting for its two derived classes, 

243 SingleFrameGaapFluxPlugin and ForcedGaapFluxPlugin which simply adapt it to 

244 the slightly different interfaces for single-frame and forced measurement. 

245 This class implements the GAaP algorithm and is intended for code reuse 

246 by the two concrete derived classes by including this mixin class. 

247 

248 Parameters 

249 ---------- 

250 config : `BaseGaapFluxConfig` 

251 Plugin configuration. 

252 name : `str` 

253 Plugin name, for registering. 

254 schema : `lsst.afw.table.Schema` 

255 The schema for the measurement output catalog. New fields will be added 

256 to hold measurements produced by this plugin. 

257 logName : `str`, optional 

258 Name to use when logging errors. This is typically provided by the 

259 measurement framework. 

260 

261 Raises 

262 ------ 

263 GaapConvolutionError 

264 Raised if the PSF Gaussianization fails for one or more target PSFs. 

265 lsst.meas.base.FatalAlgorithmError 

266 Raised if the Exposure does not contain a PSF model. 

267 """ 

268 

269 ConfigClass = BaseGaapFluxConfig 

270 hasLogName = True 

271 

272 def __init__(self, config: BaseGaapFluxConfig, name, schema, logName=None) -> None: 

273 # Flag definitions for each variant of GAaP measurement 

274 flagDefs = measBase.FlagDefinitionList() 

275 for scalingFactor, sigma in itertools.product(config.scalingFactors, config.sigmas): 

276 baseName = self.ConfigClass._getGaapResultName(scalingFactor, sigma, name) 

277 doc = f"GAaP Flux with {sigma} aperture after multiplying the seeing by {scalingFactor}" 

278 measBase.FluxResultKey.addFields(schema, name=baseName, doc=doc) 

279 

280 # Remove the prefix_ since FlagHandler prepends it 

281 middleName = self.ConfigClass._getGaapResultName(scalingFactor, sigma) 

282 flagDefs.add(schema.join(middleName, "flag_bigPsf"), "The Gaussianized PSF is " 

283 "bigger than the aperture") 

284 flagDefs.add(schema.join(middleName, "flag"), "Generic failure flag for this set of config " 

285 "parameters. ") 

286 

287 # PSF photometry 

288 if config.doPsfPhotometry: 

289 for scalingFactor in config.scalingFactors: 

290 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "PsfFlux", name) 

291 doc = f"GAaP Flux with PSF aperture after multiplying the seeing by {scalingFactor}" 

292 measBase.FluxResultKey.addFields(schema, name=baseName, doc=doc) 

293 

294 # Remove the prefix_ since FlagHandler prepends it 

295 middleName = self.ConfigClass._getGaapResultName(scalingFactor, "PsfFlux") 

296 flagDefs.add(schema.join(middleName, "flag"), "Generic failure flag for this set of config " 

297 "parameters. ") 

298 

299 if config.doOptimalPhotometry: 

300 # Add fields to hold the optimal aperture shape 

301 # OptimalPhotometry case will fetch the aperture shape from here. 

302 self.optimalShapeKey = afwTable.QuadrupoleKey.addFields(schema, schema.join(name, "OptimalShape"), 

303 doc="Pre-seeing aperture used for " 

304 "optimal GAaP photometry") 

305 for scalingFactor in config.scalingFactors: 

306 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "Optimal", name) 

307 docstring = f"GAaP Flux with optimal aperture after multiplying the seeing by {scalingFactor}" 

308 measBase.FluxResultKey.addFields(schema, name=baseName, doc=docstring) 

309 

310 # Remove the prefix_ since FlagHandler prepends it 

311 middleName = self.ConfigClass._getGaapResultName(scalingFactor, "Optimal") 

312 flagDefs.add(schema.join(middleName, "flag_bigPsf"), "The Gaussianized PSF is " 

313 "bigger than the aperture") 

314 flagDefs.add(schema.join(middleName, "flag"), "Generic failure flag for this set of config " 

315 "parameters. ") 

316 

317 if config.registerForApCorr: 

318 for baseName in config.getAllGaapResultNames(name): 

319 measBase.addApCorrName(baseName) 

320 

321 for scalingFactor in config.scalingFactors: 

322 flagName = self.ConfigClass._getGaapResultName(scalingFactor, "flag_gaussianization") 

323 flagDefs.add(flagName, "PSF Gaussianization failed when trying to scale by this factor.") 

324 

325 self.log = logging.getLogger(logName) 

326 self.flagHandler = measBase.FlagHandler.addFields(schema, name, flagDefs) 

327 self.EdgeFlagKey = schema.addField(schema.join(name, "flag_edge"), type="Flag", 

328 doc="Source is too close to the edge") 

329 self.NoPixelKey = schema.addField(schema.join(name, "flag_no_pixel"), type="Flag", 

330 doc="No pixels in the footprint") 

331 self._failKey = schema.addField(name + '_flag', type="Flag", doc="Set for any fatal failure") 

332 

333 self.psfMatchTask = config._modelPsfMatch.target(config=config._modelPsfMatch) 

334 

335 @staticmethod 

336 def _computeKernelAcf(kernel: lsst.afw.math.Kernel) -> lsst.afw.image.Image: # noqa: F821 

337 """Compute the auto-correlation function of ``kernel``. 

338 

339 Parameters 

340 ---------- 

341 kernel : `~lsst.afw.math.Kernel` 

342 The kernel for which auto-correlation function is to be computed. 

343 

344 Returns 

345 ------- 

346 acfImage : `~lsst.afw.image.Image` 

347 The two-dimensional auto-correlation function of ``kernel``. 

348 """ 

349 kernelImage = afwImage.ImageD(kernel.getDimensions()) 

350 kernel.computeImage(kernelImage, False) 

351 acfArray = scipy.signal.correlate2d(kernelImage.array, kernelImage.array, boundary='fill') 

352 acfImage = afwImage.ImageD(acfArray) 

353 return acfImage 

354 

355 @staticmethod 

356 def _getFluxErrScaling(kernelAcf: lsst.afw.image.Image, # noqa: F821 

357 aperShape: lsst.afw.geom.Quadrupole) -> float: # noqa: F821 

358 """Calculate the value by which the standard error has to be scaled due 

359 to noise correlations. 

360 

361 This calculates the correction to apply to the naively computed 

362 `instFluxErr` to account for correlations in the pixel noise introduced 

363 in the PSF-Gaussianization step. 

364 This method performs the integral in Eq. A17 of Kuijken et al. (2015). 

365 

366 The returned value equals 

367 :math:`\\int\\mathrm{d}x C^G(x) \\exp(-x^T Q^{-1}x/4)` 

368 where :math: `Q` is ``aperShape`` and :math: `C^G(x)` is ``kernelAcf``. 

369 

370 Parameters 

371 ---------- 

372 kernelAcf : `~lsst.afw.image.Image` 

373 The auto-correlation function (ACF) of the PSF matching kernel. 

374 aperShape : `~lsst.afw.geom.Quadrupole` 

375 The shape parameter of the Gaussian function which was used to 

376 measure GAaP flux. 

377 

378 Returns 

379 ------- 

380 fluxErrScaling : `float` 

381 The factor by which the standard error on GAaP flux must be scaled. 

382 """ 

383 aperShapeX2 = aperShape.convolve(aperShape) 

384 corrFlux = measBase.SdssShapeAlgorithm.computeFixedMomentsFlux(kernelAcf, aperShapeX2, 

385 kernelAcf.getBBox().getCenter()) 

386 fluxErrScaling = (0.5*corrFlux.instFlux)**0.5 

387 return fluxErrScaling 

388 

389 def _gaussianize(self, exposure: afwImage.Exposure, modelPsf: afwDetection.GaussianPsf, 

390 measRecord: lsst.afw.table.SourceRecord) -> lsst.pipe.base.Struct: # noqa: F821 

391 """Modify the ``exposure`` so that its PSF is a Gaussian. 

392 

393 Compute the convolution kernel to make the PSF same as ``modelPsf`` 

394 and return the Gaussianized exposure in a struct. 

395 

396 Parameters 

397 ---------- 

398 exposure : `~lsst.afw.image.Exposure` 

399 Original (full) exposure containing all the sources. 

400 modelPsf : `~lsst.afw.detection.GaussianPsf` 

401 Target PSF to which to match. 

402 measRecord : `~lsst.afw.tabe.SourceRecord` 

403 Record for the source to be measured. 

404 

405 Returns 

406 ------- 

407 result : `~lsst.pipe.base.Struct` 

408 ``result`` is the Struct returned by `modelPsfMatch` task. Notably, 

409 it contains a ``psfMatchedExposure``, which is the exposure 

410 containing the source, convolved to the target seeing and 

411 ``psfMatchingKernel``, the kernel that ``exposure`` was convolved 

412 by to obtain ``psfMatchedExposure``. Typically, the bounding box of 

413 ``psfMatchedExposure`` is larger than that of the footprint. 

414 """ 

415 footprint = measRecord.getFootprint() 

416 bbox = footprint.getBBox() 

417 

418 # The kernelSize is guaranteed to be odd, say 2N+1 pixels (N=10 by 

419 # default). The flux inside the footprint is smeared by N pixels on 

420 # either side, which is region of interest. So grow the bounding box 

421 # initially by N pixels on either side. 

422 pixToGrow = self.config._modelPsfMatch.kernel.active.kernelSize//2 

423 bbox.grow(pixToGrow) 

424 

425 # The bounding box may become too big and go out of bounds for sources 

426 # near the edge. Clip the subExposure to the exposure's bounding box. 

427 # Set the flag_edge marking that the bbox of the footprint could not 

428 # be grown fully but do not set it as a failure. 

429 if not exposure.getBBox().contains(bbox): 

430 bbox.clip(exposure.getBBox()) 

431 measRecord.setFlag(self.EdgeFlagKey, True) 

432 

433 subExposure = exposure[bbox] 

434 

435 # The size parameter of the basis has to be set dynamically. 

436 result = self.psfMatchTask.run(exposure=subExposure, center=measRecord.getCentroid(), 

437 targetPsfModel=modelPsf, 

438 basisSigmaGauss=[modelPsf.getSigma()]) 

439 # TODO: DM-27407 will re-Gaussianize the exposure to make the PSF even 

440 # more Gaussian-like 

441 

442 # Do not let the variance plane be rescaled since we handle it 

443 # carefully later using _getFluxScaling method 

444 result.psfMatchedExposure.variance.array = subExposure.variance.array 

445 return result 

446 

447 def _measureFlux(self, measRecord: lsst.afw.table.SourceRecord, 

448 exposure: afwImage.Exposure, kernelAcf: afwImage.Image, 

449 center: lsst.geom.Point2D, aperShape: afwGeom.Quadrupole, 

450 baseName: str, fluxScaling: Optional[float] = None) -> None: 

451 """Measure the flux and populate the record. 

452 

453 Parameters 

454 ---------- 

455 measRecord : `~lsst.afw.table.SourceRecord` 

456 Catalog record for the source being measured. 

457 exposure : `~lsst.afw.image.Exposure` 

458 Subexposure containing the deblended source being measured. 

459 The PSF attached to it should nominally be an 

460 `lsst.afw.Detection.GaussianPsf` object, but not enforced. 

461 kernelAcf : `~lsst.afw.image.Image` 

462 An image representating the auto-correlation function of the 

463 PSF-matching kernel. 

464 center : `~lsst.geom.Point2D` 

465 The centroid position of the source being measured. 

466 aperShape : `~lsst.afw.geom.Quadrupole` 

467 The shape parameter of the post-seeing Gaussian aperture. 

468 It should be a valid quadrupole if ``fluxScaling`` is specified. 

469 baseName : `str` 

470 The base name of the GAaP field. 

471 fluxScaling : `float`, optional 

472 The multiplication factor by which the measured flux has to be 

473 scaled. If `None` or unspecified, the pre-factor in Eq. A16 

474 of Kuijken et al. (2015) is computed and applied. 

475 """ 

476 if fluxScaling is None: 

477 # Calculate the pre-factor in Eq. A16 of Kuijken et al. (2015) 

478 # to scale the flux. Include an extra factor of 0.5 to undo 

479 # the normalization factor of 2 in `computeFixedMomentsFlux`. 

480 try: 

481 aperShape.normalize() 

482 # Calculate the pre-seeing aperture. 

483 preseeingShape = aperShape.convolve(exposure.getPsf().computeShape(center)) 

484 fluxScaling = 0.5*preseeingShape.getArea()/aperShape.getArea() 

485 except (InvalidParameterError, ZeroDivisionError): 

486 self._setFlag(measRecord, baseName, "bigPsf") 

487 return 

488 

489 # Calculate the integral in Eq. A17 of Kuijken et al. (2015) 

490 # ``fluxErrScaling`` contains the factors not captured by 

491 # ``fluxScaling`` and `instFluxErr`. It is 1 theoretically 

492 # if ``kernelAcf`` is a Dirac-delta function. 

493 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

494 

495 fluxResult = measBase.SdssShapeAlgorithm.computeFixedMomentsFlux(exposure.getMaskedImage(), 

496 aperShape, center) 

497 

498 # Scale the quantities in fluxResult and copy result to record 

499 fluxResult.instFlux *= fluxScaling 

500 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

501 fluxResultKey = measBase.FluxResultKey(measRecord.schema[baseName]) 

502 fluxResultKey.set(measRecord, fluxResult) 

503 

504 def _gaussianizeAndMeasure(self, measRecord: lsst.afw.table.SourceRecord, 

505 exposure: afwImage.Exposure, 

506 center: lsst.geom.Point2D) -> None: 

507 """Measure the properties of a source on a single image. 

508 

509 The image may be from a single epoch, or it may be a coadd. 

510 

511 Parameters 

512 ---------- 

513 measRecord : `~lsst.afw.table.SourceRecord` 

514 Record describing the object being measured. Previously-measured 

515 quantities may be retrieved from here, and it will be updated 

516 in-place with the outputs of this plugin. 

517 exposure : `~lsst.afw.image.ExposureF` 

518 The pixel data to be measured, together with the associated PSF, 

519 WCS, etc. All other sources in the image should have been replaced 

520 by noise according to deblender outputs. 

521 center : `~lsst.geom.Point2D` 

522 Centroid location of the source being measured. 

523 

524 Raises 

525 ------ 

526 GaapConvolutionError 

527 Raised if the PSF Gaussianization fails for any of the target PSFs. 

528 lsst.meas.base.FatalAlgorithmError 

529 Raised if the Exposure does not contain a PSF model. 

530 NoPixelError 

531 Raised if the footprint has no pixels. 

532 

533 Notes 

534 ----- 

535 This method is the entry point to the mixin from the concrete derived 

536 classes. 

537 """ 

538 # First make sure we have a PSF. 

539 if (psf := exposure.getPsf()) is None: 

540 raise measBase.FatalAlgorithmError("No PSF in exposure") 

541 

542 # Raise errors if the plugin would fail for this record for all 

543 # scaling factors and sigmas. 

544 if measRecord.getFootprint().getArea() == 0: 

545 self._setFlag(measRecord, self.name, "no_pixel") 

546 self._setScalingAndSigmaFlags(measRecord, self.config.scalingFactors) 

547 raise NoPixelError("No good pixels in footprint", 1) 

548 

549 psfSigma = psf.computeShape(center).getTraceRadius() 

550 if not (psfSigma > 0): # This captures NaN and negative values. 

551 center = measRecord.getCentroid() 

552 self.log.debug("Invalid PSF sigma; cannot solve for PSF matching kernel in GAaP for (%f, %f): %s", 

553 center.getX(), center.getY(), "GAaP Convolution Error") 

554 self._setScalingAndSigmaFlags( 

555 measRecord, 

556 self.config.scalingFactors, 

557 specificFlag="flag_gaussianization", 

558 ) 

559 raise GaapConvolutionError("Failed to solve for PSF matching kernel", 1) 

560 else: 

561 errorCollection = dict() 

562 

563 wcs = exposure.getWcs() 

564 

565 for scalingFactor in self.config.scalingFactors: 

566 targetSigma = scalingFactor*psfSigma 

567 # If this target PSF is bound to fail for all apertures, 

568 # set the flags and move on without PSF Gaussianization. 

569 if self._isAllFailure(measRecord, scalingFactor, targetSigma): 

570 continue 

571 

572 stampSize = self.config._modelPsfDimension 

573 targetPsf = afwDetection.GaussianPsf(stampSize, stampSize, targetSigma) 

574 try: 

575 result = self._gaussianize(exposure, targetPsf, measRecord) 

576 except Exception as error: 

577 errorCollection[str(scalingFactor)] = error 

578 continue 

579 

580 convolved = result.psfMatchedExposure 

581 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel) 

582 

583 measureFlux = partial(self._measureFlux, measRecord, convolved, kernelAcf, center) 

584 # Computing shape is inexpensive and position-independent for a 

585 # GaussianPsf 

586 psfShape = targetPsf.computeShape(center) 

587 

588 if self.config.doPsfPhotometry: 

589 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "PsfFlux", self.name) 

590 aperShape = psfShape 

591 measureFlux(aperShape, baseName, fluxScaling=1) 

592 

593 if self.config.doOptimalPhotometry: 

594 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "Optimal", self.name) 

595 optimalShape = measRecord.get(self.optimalShapeKey) 

596 aperShape = afwGeom.Quadrupole(optimalShape.getParameterVector() 

597 - psfShape.getParameterVector()) 

598 measureFlux(aperShape, baseName) 

599 

600 # Iterate over pre-defined circular apertures 

601 for sigma in self.config.sigmas: 

602 baseName = self.ConfigClass._getGaapResultName(scalingFactor, sigma, self.name) 

603 if sigma <= targetSigma * wcs.getPixelScale(center).asArcseconds(): 

604 # Raise when the aperture is invalid 

605 self._setFlag(measRecord, baseName, "bigPsf") 

606 continue 

607 

608 intrinsicShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0) # in sky coordinates 

609 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

610 lsst.geom.arcseconds).getLinear()) 

611 aperShape = afwGeom.Quadrupole(intrinsicShape.getParameterVector() 

612 - psfShape.getParameterVector()) 

613 measureFlux(aperShape, baseName) 

614 

615 # Raise GaapConvolutionError before exiting the plugin 

616 # if the collection of errors is not empty 

617 if errorCollection: 

618 message = "Problematic scaling factors = " 

619 message += ", ".join(errorCollection) 

620 message += " Errors: " 

621 message += " | ".join(set(msg.__repr__() for msg in errorCollection.values())) 

622 center = measRecord.getCentroid() 

623 self.log.debug("Failed to solve for PSF matching kernel in GAaP for (%f, %f): %s", 

624 center.getX(), center.getY(), message) 

625 self._setScalingAndSigmaFlags( 

626 measRecord, 

627 errorCollection.keys(), 

628 specificFlag="flag_gaussianization", 

629 ) 

630 raise GaapConvolutionError("Failed to solve for PSF matching kernel", 1) 

631 

632 @staticmethod 

633 def _setFlag(measRecord, baseName, flagName=None): 

634 """Set the GAaP flag determined by ``baseName`` and ``flagName``. 

635 

636 A convenience method to set {baseName}_flag_{flagName} to True. 

637 This also automatically sets the generic {baseName}_flag to True. 

638 To set the general plugin flag indicating measurement failure, 

639 use _failKey directly. 

640 

641 Parameters 

642 ---------- 

643 measRecord : `~lsst.afw.table.SourceRecord` 

644 Record describing the source being measured. 

645 baseName : `str` 

646 The base name of the GAaP field for which the flag must be set. 

647 flagName : `str`, optional 

648 The name of the specific flag to set along with the general flag. 

649 If unspecified, only the general flag corresponding to ``baseName`` 

650 is set. For now, the only value that can be specified is "bigPsf". 

651 """ 

652 if flagName is not None: 

653 specificFlagKey = measRecord.schema.join(baseName, f"flag_{flagName}") 

654 measRecord.set(specificFlagKey, True) 

655 genericFlagKey = measRecord.schema.join(baseName, "flag") 

656 measRecord.set(genericFlagKey, True) 

657 

658 def _setScalingAndSigmaFlags(self, measRecord, scalingFactors, specificFlag=None): 

659 """Set a full suite of flags for scalingFactors/sigmas. 

660 

661 Parameters 

662 ---------- 

663 measRecord : `~lsst.afw.table.SourceRecord` 

664 Record describing the source being measured. 

665 scalingFactors : `list` [`float`] 

666 List of scaling factors. 

667 specificFlag : `str`, optional 

668 Specific type of flag to set if needed. 

669 """ 

670 for scalingFactor in scalingFactors: 

671 if specificFlag is not None: 

672 flagName = self.ConfigClass._getGaapResultName(scalingFactor, specificFlag, 

673 self.name) 

674 measRecord.set(flagName, True) 

675 for sigma in self.config._sigmas: 

676 baseName = self.ConfigClass._getGaapResultName(scalingFactor, sigma, self.name) 

677 self._setFlag(measRecord, baseName) 

678 

679 def _isAllFailure(self, measRecord, scalingFactor, targetSigma) -> bool: 

680 """Check if all measurements would result in failure. 

681 

682 If all of the pre-seeing apertures are smaller than size of the 

683 target PSF for the given ``scalingFactor``, then set the 

684 `flag_bigPsf` for all fields corresponding to ``scalingFactor`` 

685 and move on instead of spending computational effort in 

686 Gaussianizing the exposure. 

687 

688 Parameters 

689 ---------- 

690 measRecord : `~lsst.afw.table.SourceRecord` 

691 Record describing the source being measured. 

692 scalingFactor : `float` 

693 The multiplicative factor by which the seeing is scaled. 

694 targetSigma : `float` 

695 Sigma (in pixels) of the target circular Gaussian PSF. 

696 

697 Returns 

698 ------- 

699 allFailure : `bool` 

700 A boolean value indicating whether all measurements would fail. 

701 

702 Notes 

703 ---- 

704 If doPsfPhotometry is set to True, then this will always return False. 

705 """ 

706 if self.config.doPsfPhotometry: 

707 return False 

708 

709 allFailure = targetSigma >= max(self.config.sigmas) 

710 # If measurements would fail on all circular apertures, and if 

711 # optimal elliptical aperture is used, check if that would also fail. 

712 if self.config.doOptimalPhotometry and allFailure: 

713 optimalShape = measRecord.get(self.optimalShapeKey) 

714 aperShape = afwGeom.Quadrupole(optimalShape.getParameterVector() 

715 - [targetSigma**2, targetSigma**2, 0.0]) 

716 allFailure = (aperShape.getIxx() <= 0) or (aperShape.getIyy() <= 0) or (aperShape.getArea() <= 0) 

717 

718 # Set all failure flags if allFailure is True. 

719 if allFailure: 

720 if self.config.doOptimalPhotometry: 

721 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "Optimal", self.name) 

722 self._setFlag(measRecord, baseName, "bigPsf") 

723 for sigma in self.config.sigmas: 

724 baseName = self.ConfigClass._getGaapResultName(scalingFactor, sigma, self.name) 

725 self._setFlag(measRecord, baseName, "bigPsf") 

726 

727 return allFailure 

728 

729 def fail(self, measRecord, error=None): 

730 """Record a measurement failure. 

731 

732 This default implementation simply records the failure in the source 

733 record and is inherited by the SingleFrameGaapFluxPlugin and 

734 ForcedGaapFluxPlugin. 

735 

736 Parameters 

737 ---------- 

738 measRecord : `lsst.afw.table.SourceRecord` 

739 Catalog record for the source being measured. 

740 error : `Exception` 

741 Error causing failure, or `None`. 

742 """ 

743 # We only need to set the failKey if no error was specified which 

744 # signifies that the flagging was already handled. 

745 if error is None: 

746 measRecord.set(self._failKey, True) 

747 

748 

749class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

750 measBase.SingleFramePluginConfig): 

751 """Config for SingleFrameGaapFluxPlugin.""" 

752 

753 

754@measBase.register(PLUGIN_NAME) 

755class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

756 """Gaussian Aperture and PSF photometry algorithm in single-frame mode. 

757 

758 Parameters 

759 ---------- 

760 config : `GaapFluxConfig` 

761 Plugin configuration. 

762 name : `str` 

763 Plugin name, for registering. 

764 schema : `lsst.afw.table.Schema` 

765 The schema for the measurement output catalog. New fields will be added 

766 to hold measurements produced by this plugin. 

767 metadata : `lsst.daf.base.PropertySet` 

768 Plugin metadata that will be attached to the output catalog. 

769 logName : `str`, optional 

770 Name to use when logging errors. This will be provided by the 

771 measurement framework. 

772 

773 Notes 

774 ----- 

775 This plugin must be run in forced mode to produce consistent colors across 

776 the different bandpasses. 

777 """ 

778 ConfigClass = SingleFrameGaapFluxConfig 

779 

780 def __init__(self, config, name, schema, metadata, logName=None): 

781 BaseGaapFluxMixin.__init__(self, config, name, schema, logName=logName) 

782 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata, logName=logName) 

783 

784 @classmethod 

785 def getExecutionOrder(cls) -> float: 

786 # Docstring inherited 

787 return cls.FLUX_ORDER 

788 

789 def measure(self, measRecord, exposure): 

790 # Docstring inherited. 

791 center = measRecord.getCentroid() 

792 if self.config.doOptimalPhotometry: 

793 # The adaptive shape is set to post-seeing aperture. 

794 # Convolve with the PSF shape to obtain pre-seeing aperture. 

795 # Refer to pg. 30-31 of Kuijken et al. (2015) for this heuristic. 

796 # psfShape = measRecord.getPsfShape() # TODO: DM-30229 

797 psfShape = afwTable.QuadrupoleKey(measRecord.schema["slot_PsfShape"]).get(measRecord) 

798 optimalShape = measRecord.getShape().convolve(psfShape) 

799 # Record the aperture used for optimal photometry 

800 measRecord.set(self.optimalShapeKey, optimalShape) 

801 self._gaussianizeAndMeasure(measRecord, exposure, center) 

802 

803 

804class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

805 """Config for ForcedGaapFluxPlugin.""" 

806 

807 

808@measBase.register(PLUGIN_NAME) 

809class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

810 """Gaussian Aperture and PSF (GAaP) photometry plugin in forced mode. 

811 

812 This is the GAaP plugin to run for consistent colors across the bandpasses. 

813 

814 Parameters 

815 ---------- 

816 config : `GaapFluxConfig` 

817 Plugin configuration. 

818 name : `str` 

819 Plugin name, for registering. 

820 schemaMapper : `lsst.afw.table.SchemaMapper` 

821 A mapping from reference catalog fields to output catalog fields. 

822 Output fields will be added to the output schema. 

823 for the measurement output catalog. New fields will be added 

824 to hold measurements produced by this plugin. 

825 metadata : `lsst.daf.base.PropertySet` 

826 Plugin metadata that will be attached to the output catalog. 

827 logName : `str`, optional 

828 Name to use when logging errors. This will be provided by the 

829 measurement framework. 

830 """ 

831 ConfigClass = ForcedGaapFluxConfig 

832 

833 def __init__(self, config, name, schemaMapper, metadata, logName=None): 

834 schema = schemaMapper.editOutputSchema() 

835 BaseGaapFluxMixin.__init__(self, config, name, schema, logName=logName) 

836 measBase.ForcedPlugin.__init__(self, config, name, schemaMapper, metadata, logName=logName) 

837 

838 @classmethod 

839 def getExecutionOrder(cls) -> float: 

840 # Docstring inherited. 

841 return cls.FLUX_ORDER 

842 

843 def measure(self, measRecord, exposure, refRecord, refWcs): 

844 # Docstring inherited. 

845 wcs = exposure.getWcs() 

846 center = wcs.skyToPixel(refWcs.pixelToSky(refRecord.getCentroid())) 

847 if self.config.doOptimalPhotometry: 

848 # The adaptive shape is set to post-seeing aperture. 

849 # Convolve it with the PSF shape to obtain pre-seeing aperture. 

850 # Refer to pg. 30-31 of Kuijken et al. (2015) for this heuristic. 

851 # psfShape = refRecord.getPsfShape() # TODO: DM-30229 

852 psfShape = afwTable.QuadrupoleKey(refRecord.schema["slot_PsfShape"]).get(refRecord) 

853 optimalShape = refRecord.getShape().convolve(psfShape) 

854 if not (wcs == refWcs): 

855 measFromSky = wcs.linearizeSkyToPixel(measRecord.getCentroid(), lsst.geom.radians) 

856 skyFromRef = refWcs.linearizePixelToSky(refRecord.getCentroid(), lsst.geom.radians) 

857 measFromRef = measFromSky*skyFromRef 

858 optimalShape.transformInPlace(measFromRef.getLinear()) 

859 # Record the intrinsic aperture used for optimal photometry. 

860 measRecord.set(self.optimalShapeKey, optimalShape) 

861 self._gaussianizeAndMeasure(measRecord, exposure, center)