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

277 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-03 03:39 -0700

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 """Collection of any unexpected errors in GAaP during PSF Gaussianization. 

48 

49 The PSF Gaussianization procedure using `modelPsfMatchTask` may throw 

50 exceptions for certain target PSFs. Such errors are caught until all 

51 measurements are at least attempted. The complete traceback information 

52 is lost, but unique error messages are preserved. 

53 

54 Parameters 

55 ---------- 

56 errors : `dict` [`str`, `Exception`] 

57 The values are exceptions raised, while the keys are the loop variables 

58 (in `str` format) where the exceptions were raised. 

59 """ 

60 def __init__(self, errors: dict[str, Exception]): 

61 self.errorDict = errors 

62 message = "Problematic scaling factors = " 

63 message += ", ".join(errors) 

64 message += " Errors: " 

65 message += " | ".join(set(msg.__repr__() for msg in errors.values())) # msg.cpp.what() misses type 

66 super().__init__(message, 1) # the second argument does not matter. 

67 

68 

69class NoPixelError(Exception): 

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

71 

72 This is caught by the measurement framework, which then calls the 

73 `fail` method of the plugin without passing in a value for `error`. 

74 """ 

75 

76 

77class BaseGaapFluxConfig(measBase.BaseMeasurementPluginConfig): 

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

79 """ 

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

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

82 """ 

83 return x >= 1 

84 

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

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

87 """ 

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

89 

90 sigmas = pexConfig.ListField( 

91 dtype=float, 

92 default=[0.7, 1.0], 

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

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

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

96 ) 

97 

98 scalingFactors = pexConfig.ListField( 

99 dtype=float, 

100 default=[1.15], 

101 itemCheck=_greaterThanOrEqualToUnity, 

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

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

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

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

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

107 ) 

108 

109 _modelPsfMatch = pexConfig.ConfigurableField( 

110 target=GaussianizePsfTask, 

111 doc="PSF Gaussianization Task" 

112 ) 

113 

114 _modelPsfDimension = pexConfig.Field( 

115 dtype=int, 

116 default=65, 

117 check=_isOdd, 

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

119 ) 

120 

121 doPsfPhotometry = pexConfig.Field( 

122 dtype=bool, 

123 default=False, 

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

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

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

127 ) 

128 

129 doOptimalPhotometry = pexConfig.Field( 

130 dtype=bool, 

131 default=True, 

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

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

134 ) 

135 

136 registerForApCorr = pexConfig.Field( 

137 dtype=bool, 

138 default=True, 

139 doc="Register measurements for aperture correction? " 

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

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

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

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

144 ) 

145 

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

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

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

149 @property 

150 def scaleByFwhm(self) -> bool: 

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

152 Scale kernelSize, alardGaussians by input Fwhm? 

153 """ 

154 return self._modelPsfMatch.kernel.active.scaleByFwhm 

155 

156 @scaleByFwhm.setter 

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

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

159 

160 @property 

161 def gaussianizationMethod(self) -> str: 

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

163 return self._modelPsfMatch.convolutionMethod 

164 

165 @gaussianizationMethod.setter 

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

167 self._modelPsfMatch.convolutionMethod = value 

168 

169 @property 

170 def _sigmas(self) -> list: 

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

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

173 """ 

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

175 

176 def setDefaults(self) -> None: 

177 # Docstring inherited 

178 # TODO: DM-27482 might change these values. 

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

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

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

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

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

184 self.scaleByFwhm = True 

185 

186 def validate(self): 

187 super().validate() 

188 self._modelPsfMatch.validate() 

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

190 

191 @staticmethod 

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

193 """Return the base name for GAaP fields 

194 

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

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

197 "ext_gaap_GaapFlux_1_15x_0_7". 

198 

199 Notes 

200 ----- 

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

202 to the input arguments. Instead, users should use 

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

204 

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

206 from GAaP measurements available outside the plugin. 

207 

208 Parameters 

209 ---------- 

210 scalingFactor : `float` 

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

212 sigma : `float` or `str` 

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

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

215 name : `str`, optional 

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

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

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

219 without the leading underscore is returned. 

220 

221 Returns 

222 ------- 

223 baseName : `str` 

224 Base name for GAaP field. 

225 """ 

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

227 if name is None: 

228 return suffix 

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

230 

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

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

233 

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

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

236 ("ext_gaap_GaapFlux_1_15x_0_7", "ext_gaap_GaapFlux_1_15x_1_0") when 

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

238 "ext_gaap_GaapFlux_1_15x_PsfFlux" if `doPsfPhotometry` is True. 

239 

240 Parameters 

241 ---------- 

242 name : `str`, optional 

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

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

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

246 for example) without the leading underscores are returned. 

247 

248 Returns 

249 ------- 

250 baseNames : `generator` 

251 A generator expression yielding all the base names. 

252 """ 

253 scalingFactors = self.scalingFactors 

254 sigmas = self._sigmas 

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

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

257 return baseNames 

258 

259 

260class BaseGaapFluxMixin: 

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

262 algorithm. 

263 

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

265 SingleFrameGaapFluxPlugin and ForcedGaapFluxPlugin which simply adapt it to 

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

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

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

269 

270 Parameters 

271 ---------- 

272 config : `BaseGaapFluxConfig` 

273 Plugin configuration. 

274 name : `str` 

275 Plugin name, for registering. 

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

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

278 to hold measurements produced by this plugin. 

279 logName : `str`, optional 

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

281 measurement framework. 

282 

283 Raises 

284 ------ 

285 GaapConvolutionError 

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

287 lsst.meas.base.FatalAlgorithmError 

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

289 """ 

290 

291 ConfigClass = BaseGaapFluxConfig 

292 hasLogName = True 

293 

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

295 # Flag definitions for each variant of GAaP measurement 

296 flagDefs = measBase.FlagDefinitionList() 

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

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

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

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

301 

302 # Remove the prefix_ since FlagHandler prepends it 

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

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

305 "bigger than the aperture") 

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

307 "parameters. ") 

308 

309 # PSF photometry 

310 if config.doPsfPhotometry: 

311 for scalingFactor in config.scalingFactors: 

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

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

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

315 

316 # Remove the prefix_ since FlagHandler prepends it 

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

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

319 "parameters. ") 

320 

321 if config.doOptimalPhotometry: 

322 # Add fields to hold the optimal aperture shape 

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

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

325 doc="Pre-seeing aperture used for " 

326 "optimal GAaP photometry") 

327 for scalingFactor in config.scalingFactors: 

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

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

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

331 

332 # Remove the prefix_ since FlagHandler prepends it 

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

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

335 "bigger than the aperture") 

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

337 "parameters. ") 

338 

339 if config.registerForApCorr: 

340 for baseName in config.getAllGaapResultNames(name): 

341 measBase.addApCorrName(baseName) 

342 

343 for scalingFactor in config.scalingFactors: 

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

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

346 

347 self.log = logging.getLogger(logName) 

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

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

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

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

352 doc="No pixels in the footprint") 

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

354 

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

356 

357 @staticmethod 

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

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

360 

361 Parameters 

362 ---------- 

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

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

365 

366 Returns 

367 ------- 

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

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

370 """ 

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

372 kernel.computeImage(kernelImage, False) 

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

374 acfImage = afwImage.ImageD(acfArray) 

375 return acfImage 

376 

377 @staticmethod 

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

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

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

381 to noise correlations. 

382 

383 This calculates the correction to apply to the naively computed 

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

385 in the PSF-Gaussianization step. 

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

387 

388 The returned value equals 

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

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

391 

392 Parameters 

393 ---------- 

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

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

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

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

398 measure GAaP flux. 

399 

400 Returns 

401 ------- 

402 fluxErrScaling : `float` 

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

404 """ 

405 aperShapeX2 = aperShape.convolve(aperShape) 

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

407 kernelAcf.getBBox().getCenter()) 

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

409 return fluxErrScaling 

410 

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

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

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

414 

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

416 and return the Gaussianized exposure in a struct. 

417 

418 Parameters 

419 ---------- 

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

421 Original (full) exposure containing all the sources. 

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

423 Target PSF to which to match. 

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

425 Record for the source to be measured. 

426 

427 Returns 

428 ------- 

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

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

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

432 containing the source, convolved to the target seeing and 

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

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

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

436 """ 

437 footprint = measRecord.getFootprint() 

438 bbox = footprint.getBBox() 

439 

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

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

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

443 # initially by N pixels on either side. 

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

445 bbox.grow(pixToGrow) 

446 

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

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

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

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

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

452 bbox.clip(exposure.getBBox()) 

453 measRecord.setFlag(self.EdgeFlagKey, True) 

454 

455 subExposure = exposure[bbox] 

456 

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

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

459 targetPsfModel=modelPsf, 

460 basisSigmaGauss=[modelPsf.getSigma()]) 

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

462 # more Gaussian-like 

463 

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

465 # carefully later using _getFluxScaling method 

466 result.psfMatchedExposure.variance.array = subExposure.variance.array 

467 return result 

468 

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

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

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

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

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

474 

475 Parameters 

476 ---------- 

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

478 Catalog record for the source being measured. 

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

480 Subexposure containing the deblended source being measured. 

481 The PSF attached to it should nominally be an 

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

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

484 An image representating the auto-correlation function of the 

485 PSF-matching kernel. 

486 center : `~lsst.geom.Point2D` 

487 The centroid position of the source being measured. 

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

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

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

491 baseName : `str` 

492 The base name of the GAaP field. 

493 fluxScaling : `float`, optional 

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

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

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

497 """ 

498 if fluxScaling is None: 

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

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

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

502 try: 

503 aperShape.normalize() 

504 # Calculate the pre-seeing aperture. 

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

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

507 except (InvalidParameterError, ZeroDivisionError): 

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

509 return 

510 

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

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

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

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

515 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

516 

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

518 aperShape, center) 

519 

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

521 fluxResult.instFlux *= fluxScaling 

522 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

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

524 fluxResultKey.set(measRecord, fluxResult) 

525 

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

527 exposure: afwImage.Exposure, 

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

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

530 

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

532 

533 Parameters 

534 ---------- 

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

536 Record describing the object being measured. Previously-measured 

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

538 in-place with the outputs of this plugin. 

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

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

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

542 by noise according to deblender outputs. 

543 center : `~lsst.geom.Point2D` 

544 Centroid location of the source being measured. 

545 

546 Raises 

547 ------ 

548 GaapConvolutionError 

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

550 lsst.meas.base.FatalAlgorithmError 

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

552 NoPixelError 

553 Raised if the footprint has no pixels. 

554 

555 Notes 

556 ----- 

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

558 classes. 

559 """ 

560 

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

562 # scaling factors and sigmas. 

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

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

565 raise NoPixelError 

566 

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

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

569 

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

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

572 errorCollection = {str(scalingFactor): measBase.MeasurementError("PSF size could not be measured") 

573 for scalingFactor in self.config.scalingFactor} 

574 raise GaapConvolutionError(errorCollection) 

575 else: 

576 errorCollection = dict() 

577 

578 wcs = exposure.getWcs() 

579 

580 for scalingFactor in self.config.scalingFactors: 

581 targetSigma = scalingFactor*psfSigma 

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

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

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

585 continue 

586 

587 stampSize = self.config._modelPsfDimension 

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

589 try: 

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

591 except Exception as error: 

592 errorCollection[str(scalingFactor)] = error 

593 continue 

594 

595 convolved = result.psfMatchedExposure 

596 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel) 

597 

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

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

600 # GaussianPsf 

601 psfShape = targetPsf.computeShape(center) 

602 

603 if self.config.doPsfPhotometry: 

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

605 aperShape = psfShape 

606 measureFlux(aperShape, baseName, fluxScaling=1) 

607 

608 if self.config.doOptimalPhotometry: 

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

610 optimalShape = measRecord.get(self.optimalShapeKey) 

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

612 - psfShape.getParameterVector()) 

613 measureFlux(aperShape, baseName) 

614 

615 # Iterate over pre-defined circular apertures 

616 for sigma in self.config.sigmas: 

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

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

619 # Raise when the aperture is invalid 

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

621 continue 

622 

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

624 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

625 lsst.geom.arcseconds).getLinear()) 

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

627 - psfShape.getParameterVector()) 

628 measureFlux(aperShape, baseName) 

629 

630 # Raise GaapConvolutionError before exiting the plugin 

631 # if the collection of errors is not empty 

632 if errorCollection: 

633 raise GaapConvolutionError(errorCollection) 

634 

635 @staticmethod 

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

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

638 

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

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

641 To set the general plugin flag indicating measurement failure, 

642 use _failKey directly. 

643 

644 Parameters 

645 ---------- 

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

647 Record describing the source being measured. 

648 baseName : `str` 

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

650 flagName : `str`, optional 

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

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

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

654 """ 

655 if flagName is not None: 

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

657 measRecord.set(specificFlagKey, True) 

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

659 measRecord.set(genericFlagKey, True) 

660 

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

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

663 

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

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

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

667 and move on instead of spending computational effort in 

668 Gaussianizing the exposure. 

669 

670 Parameters 

671 ---------- 

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

673 Record describing the source being measured. 

674 scalingFactor : `float` 

675 The multiplicative factor by which the seeing is scaled. 

676 targetSigma : `float` 

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

678 

679 Returns 

680 ------- 

681 allFailure : `bool` 

682 A boolean value indicating whether all measurements would fail. 

683 

684 Notes 

685 ---- 

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

687 """ 

688 if self.config.doPsfPhotometry: 

689 return False 

690 

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

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

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

694 if self.config.doOptimalPhotometry and allFailure: 

695 optimalShape = measRecord.get(self.optimalShapeKey) 

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

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

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

699 

700 # Set all failure flags if allFailure is True. 

701 if allFailure: 

702 if self.config.doOptimalPhotometry: 

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

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

705 for sigma in self.config.sigmas: 

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

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

708 

709 return allFailure 

710 

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

712 """Record a measurement failure. 

713 

714 This default implementation simply records the failure in the source 

715 record and is inherited by the SingleFrameGaapFluxPlugin and 

716 ForcedGaapFluxPlugin. 

717 

718 Parameters 

719 ---------- 

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

721 Catalog record for the source being measured. 

722 error : `Exception` 

723 Error causing failure, or `None`. 

724 """ 

725 if error is not None: 

726 center = measRecord.getCentroid() 

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

728 center.getX(), center.getY(), error) 

729 for scalingFactor in error.errorDict: 

730 flagName = self.ConfigClass._getGaapResultName(scalingFactor, "flag_gaussianization", 

731 self.name) 

732 measRecord.set(flagName, True) 

733 for sigma in self.config._sigmas: 

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

735 self._setFlag(measRecord, baseName) 

736 else: 

737 measRecord.set(self._failKey, True) 

738 

739 

740class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

741 measBase.SingleFramePluginConfig): 

742 """Config for SingleFrameGaapFluxPlugin.""" 

743 

744 

745@measBase.register(PLUGIN_NAME) 

746class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

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

748 

749 Parameters 

750 ---------- 

751 config : `GaapFluxConfig` 

752 Plugin configuration. 

753 name : `str` 

754 Plugin name, for registering. 

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

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

757 to hold measurements produced by this plugin. 

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

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

760 logName : `str`, optional 

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

762 measurement framework. 

763 

764 Notes 

765 ----- 

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

767 the different bandpasses. 

768 """ 

769 ConfigClass = SingleFrameGaapFluxConfig 

770 

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

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

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

774 

775 @classmethod 

776 def getExecutionOrder(cls) -> float: 

777 # Docstring inherited 

778 return cls.FLUX_ORDER 

779 

780 def measure(self, measRecord, exposure): 

781 # Docstring inherited. 

782 center = measRecord.getCentroid() 

783 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

790 # Record the aperture used for optimal photometry 

791 measRecord.set(self.optimalShapeKey, optimalShape) 

792 self._gaussianizeAndMeasure(measRecord, exposure, center) 

793 

794 

795class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

796 """Config for ForcedGaapFluxPlugin.""" 

797 

798 

799@measBase.register(PLUGIN_NAME) 

800class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

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

802 

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

804 

805 Parameters 

806 ---------- 

807 config : `GaapFluxConfig` 

808 Plugin configuration. 

809 name : `str` 

810 Plugin name, for registering. 

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

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

813 Output fields will be added to the output schema. 

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

815 to hold measurements produced by this plugin. 

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

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

818 logName : `str`, optional 

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

820 measurement framework. 

821 """ 

822 ConfigClass = ForcedGaapFluxConfig 

823 

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

825 schema = schemaMapper.editOutputSchema() 

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

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

828 

829 @classmethod 

830 def getExecutionOrder(cls) -> float: 

831 # Docstring inherited. 

832 return cls.FLUX_ORDER 

833 

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

835 # Docstring inherited. 

836 wcs = exposure.getWcs() 

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

838 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

845 if not (wcs == refWcs): 

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

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

848 measFromRef = measFromSky*skyFromRef 

849 optimalShape.transformInPlace(measFromRef.getLinear()) 

850 # Record the intrinsic aperture used for optimal photometry. 

851 measRecord.set(self.optimalShapeKey, optimalShape) 

852 self._gaussianizeAndMeasure(measRecord, exposure, center)