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

273 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-28 05:21 -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 BaseGaapFluxConfig(measBase.BaseMeasurementPluginConfig): 

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

71 """ 

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

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

74 """ 

75 return x >= 1 

76 

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

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

79 """ 

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

81 

82 sigmas = pexConfig.ListField( 

83 dtype=float, 

84 default=[0.7, 1.0], 

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

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

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

88 ) 

89 

90 scalingFactors = pexConfig.ListField( 

91 dtype=float, 

92 default=[1.15], 

93 itemCheck=_greaterThanOrEqualToUnity, 

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

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

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

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

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

99 ) 

100 

101 _modelPsfMatch = pexConfig.ConfigurableField( 

102 target=GaussianizePsfTask, 

103 doc="PSF Gaussianization Task" 

104 ) 

105 

106 _modelPsfDimension = pexConfig.Field( 

107 dtype=int, 

108 default=65, 

109 check=_isOdd, 

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

111 ) 

112 

113 doPsfPhotometry = pexConfig.Field( 

114 dtype=bool, 

115 default=False, 

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

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

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

119 ) 

120 

121 doOptimalPhotometry = pexConfig.Field( 

122 dtype=bool, 

123 default=True, 

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

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

126 ) 

127 

128 registerForApCorr = pexConfig.Field( 

129 dtype=bool, 

130 default=True, 

131 doc="Register measurements for aperture correction? " 

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

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

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

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

136 ) 

137 

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

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

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

141 @property 

142 def scaleByFwhm(self) -> bool: 

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

144 Scale kernelSize, alardGaussians by input Fwhm? 

145 """ 

146 return self._modelPsfMatch.kernel.active.scaleByFwhm 

147 

148 @scaleByFwhm.setter 

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

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

151 

152 @property 

153 def gaussianizationMethod(self) -> str: 

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

155 return self._modelPsfMatch.convolutionMethod 

156 

157 @gaussianizationMethod.setter 

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

159 self._modelPsfMatch.convolutionMethod = value 

160 

161 @property 

162 def _sigmas(self) -> list: 

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

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

165 """ 

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

167 

168 def setDefaults(self) -> None: 

169 # Docstring inherited 

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

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

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

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

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

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

176 self.scaleByFwhm = True 

177 

178 def validate(self): 

179 super().validate() 

180 self._modelPsfMatch.validate() 

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

182 

183 @staticmethod 

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

185 """Return the base name for GAaP fields 

186 

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

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

189 "ext_gaap_GaapFlux_1_15x_0_7". 

190 

191 Notes 

192 ----- 

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

194 to the input arguments. Instead, users should use 

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

196 

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

198 from GAaP measurements available outside the plugin. 

199 

200 Parameters 

201 ---------- 

202 scalingFactor : `float` 

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

204 sigma : `float` or `str` 

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

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

207 name : `str`, optional 

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

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

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

211 without the leading underscore is returned. 

212 

213 Returns 

214 ------- 

215 baseName : `str` 

216 Base name for GAaP field. 

217 """ 

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

219 if name is None: 

220 return suffix 

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

222 

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

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

225 

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

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

228 ("ext_gaap_GaapFlux_1_15x_0_7", "ext_gaap_GaapFlux_1_15x_1_0") when 

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

230 "ext_gaap_GaapFlux_1_15x_PsfFlux" if `doPsfPhotometry` is True. 

231 

232 Parameters 

233 ---------- 

234 name : `str`, optional 

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

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

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

238 for example) without the leading underscores are returned. 

239 

240 Returns 

241 ------- 

242 baseNames : `generator` 

243 A generator expression yielding all the base names. 

244 """ 

245 scalingFactors = self.scalingFactors 

246 sigmas = self._sigmas 

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

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

249 return baseNames 

250 

251 

252class BaseGaapFluxMixin: 

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

254 algorithm. 

255 

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

257 SingleFrameGaapFluxPlugin and ForcedGaapFluxPlugin which simply adapt it to 

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

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

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

261 

262 Parameters 

263 ---------- 

264 config : `BaseGaapFluxConfig` 

265 Plugin configuration. 

266 name : `str` 

267 Plugin name, for registering. 

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

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

270 to hold measurements produced by this plugin. 

271 logName : `str`, optional 

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

273 measurement framework. 

274 

275 Raises 

276 ------ 

277 GaapConvolutionError 

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

279 lsst.meas.base.FatalAlgorithmError 

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

281 """ 

282 

283 ConfigClass = BaseGaapFluxConfig 

284 hasLogName = True 

285 

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

287 # Flag definitions for each variant of GAaP measurement 

288 flagDefs = measBase.FlagDefinitionList() 

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

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

291 doc = f"GAaP Flux with {sigma} 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, sigma) 

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

297 "bigger than the aperture") 

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

299 "parameters. ") 

300 

301 # PSF photometry 

302 if config.doPsfPhotometry: 

303 for scalingFactor in config.scalingFactors: 

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

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

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

307 

308 # Remove the prefix_ since FlagHandler prepends it 

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

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

311 "parameters. ") 

312 

313 if config.doOptimalPhotometry: 

314 # Add fields to hold the optimal aperture shape 

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

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

317 doc="Pre-seeing aperture used for " 

318 "optimal GAaP photometry") 

319 for scalingFactor in config.scalingFactors: 

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

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

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

323 

324 # Remove the prefix_ since FlagHandler prepends it 

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

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

327 "bigger than the aperture") 

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

329 "parameters. ") 

330 

331 if config.registerForApCorr: 

332 for baseName in config.getAllGaapResultNames(name): 

333 measBase.addApCorrName(baseName) 

334 

335 for scalingFactor in config.scalingFactors: 

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

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

338 

339 self.log = logging.getLogger(logName) 

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

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

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

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

344 

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

346 

347 @staticmethod 

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

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

350 

351 Parameters 

352 ---------- 

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

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

355 

356 Returns 

357 ------- 

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

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

360 """ 

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

362 kernel.computeImage(kernelImage, False) 

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

364 acfImage = afwImage.ImageD(acfArray) 

365 return acfImage 

366 

367 @staticmethod 

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

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

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

371 to noise correlations. 

372 

373 This calculates the correction to apply to the naively computed 

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

375 in the PSF-Gaussianization step. 

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

377 

378 The returned value equals 

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

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

381 

382 Parameters 

383 ---------- 

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

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

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

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

388 measure GAaP flux. 

389 

390 Returns 

391 ------- 

392 fluxErrScaling : `float` 

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

394 """ 

395 aperShapeX2 = aperShape.convolve(aperShape) 

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

397 kernelAcf.getBBox().getCenter()) 

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

399 return fluxErrScaling 

400 

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

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

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

404 

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

406 and return the Gaussianized exposure in a struct. 

407 

408 Parameters 

409 ---------- 

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

411 Original (full) exposure containing all the sources. 

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

413 Target PSF to which to match. 

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

415 Record for the source to be measured. 

416 

417 Returns 

418 ------- 

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

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

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

422 containing the source, convolved to the target seeing and 

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

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

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

426 """ 

427 footprint = measRecord.getFootprint() 

428 bbox = footprint.getBBox() 

429 

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

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

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

433 # initially by N pixels on either side. 

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

435 bbox.grow(pixToGrow) 

436 

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

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

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

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

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

442 bbox.clip(exposure.getBBox()) 

443 measRecord.setFlag(self.EdgeFlagKey, True) 

444 

445 subExposure = exposure[bbox] 

446 

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

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

449 targetPsfModel=modelPsf, 

450 basisSigmaGauss=[modelPsf.getSigma()]) 

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

452 # more Gaussian-like 

453 

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

455 # carefully later using _getFluxScaling method 

456 result.psfMatchedExposure.variance.array = subExposure.variance.array 

457 return result 

458 

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

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

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

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

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

464 

465 Parameters 

466 ---------- 

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

468 Catalog record for the source being measured. 

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

470 Subexposure containing the deblended source being measured. 

471 The PSF attached to it should nominally be an 

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

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

474 An image representating the auto-correlation function of the 

475 PSF-matching kernel. 

476 center : `~lsst.geom.Point2D` 

477 The centroid position of the source being measured. 

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

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

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

481 baseName : `str` 

482 The base name of the GAaP field. 

483 fluxScaling : `float`, optional 

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

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

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

487 """ 

488 if fluxScaling is None: 

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

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

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

492 try: 

493 aperShape.normalize() 

494 # Calculate the pre-seeing aperture. 

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

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

497 except (InvalidParameterError, ZeroDivisionError): 

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

499 return 

500 

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

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

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

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

505 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

506 

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

508 aperShape, center) 

509 

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

511 fluxResult.instFlux *= fluxScaling 

512 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

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

514 fluxResultKey.set(measRecord, fluxResult) 

515 

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

517 exposure: afwImage.Exposure, 

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

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

520 

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

522 

523 Parameters 

524 ---------- 

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

526 Record describing the object being measured. Previously-measured 

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

528 in-place with the outputs of this plugin. 

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

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

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

532 by noise according to deblender outputs. 

533 center : `~lsst.geom.Point2D` 

534 Centroid location of the source being measured. 

535 

536 Raises 

537 ------ 

538 GaapConvolutionError 

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

540 lsst.meas.base.FatalAlgorithmError 

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

542 

543 Notes 

544 ----- 

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

546 classes. 

547 """ 

548 psf = exposure.getPsf() 

549 if psf is None: 

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

551 wcs = exposure.getWcs() 

552 

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

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

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

556 for scalingFactor in self.config.scalingFactor} 

557 raise GaapConvolutionError(errorCollection) 

558 else: 

559 errorCollection = dict() 

560 

561 for scalingFactor in self.config.scalingFactors: 

562 targetSigma = scalingFactor*psfSigma 

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

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

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

566 continue 

567 

568 stampSize = self.config._modelPsfDimension 

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

570 try: 

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

572 except Exception as error: 

573 errorCollection[str(scalingFactor)] = error 

574 continue 

575 

576 convolved = result.psfMatchedExposure 

577 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel) 

578 

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

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

581 # GaussianPsf 

582 psfShape = targetPsf.computeShape(center) 

583 

584 if self.config.doPsfPhotometry: 

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

586 aperShape = psfShape 

587 measureFlux(aperShape, baseName, fluxScaling=1) 

588 

589 if self.config.doOptimalPhotometry: 

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

591 optimalShape = measRecord.get(self.optimalShapeKey) 

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

593 - psfShape.getParameterVector()) 

594 measureFlux(aperShape, baseName) 

595 

596 # Iterate over pre-defined circular apertures 

597 for sigma in self.config.sigmas: 

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

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

600 # Raise when the aperture is invalid 

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

602 continue 

603 

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

605 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

606 lsst.geom.arcseconds).getLinear()) 

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

608 - psfShape.getParameterVector()) 

609 measureFlux(aperShape, baseName) 

610 

611 # Raise GaapConvolutionError before exiting the plugin 

612 # if the collection of errors is not empty 

613 if errorCollection: 

614 raise GaapConvolutionError(errorCollection) 

615 

616 @staticmethod 

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

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

619 

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

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

622 To set the general plugin flag indicating measurement failure, 

623 use _failKey directly. 

624 

625 Parameters 

626 ---------- 

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

628 Record describing the source being measured. 

629 baseName : `str` 

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

631 flagName : `str`, optional 

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

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

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

635 """ 

636 if flagName is not None: 

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

638 measRecord.set(specificFlagKey, True) 

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

640 measRecord.set(genericFlagKey, True) 

641 

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

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

644 

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

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

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

648 and move on instead of spending computational effort in 

649 Gaussianizing the exposure. 

650 

651 Parameters 

652 ---------- 

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

654 Record describing the source being measured. 

655 scalingFactor : `float` 

656 The multiplicative factor by which the seeing is scaled. 

657 targetSigma : `float` 

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

659 

660 Returns 

661 ------- 

662 allFailure : `bool` 

663 A boolean value indicating whether all measurements would fail. 

664 

665 Notes 

666 ---- 

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

668 """ 

669 if self.config.doPsfPhotometry: 

670 return False 

671 

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

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

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

675 if self.config.doOptimalPhotometry and allFailure: 

676 optimalShape = measRecord.get(self.optimalShapeKey) 

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

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

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

680 

681 # Set all failure flags if allFailure is True. 

682 if allFailure: 

683 if self.config.doOptimalPhotometry: 

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

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

686 for sigma in self.config.sigmas: 

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

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

689 

690 return allFailure 

691 

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

693 """Record a measurement failure. 

694 

695 This default implementation simply records the failure in the source 

696 record and is inherited by the SingleFrameGaapFluxPlugin and 

697 ForcedGaapFluxPlugin. 

698 

699 Parameters 

700 ---------- 

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

702 Catalog record for the source being measured. 

703 error : `Exception` 

704 Error causing failure, or `None`. 

705 """ 

706 if error is not None: 

707 center = measRecord.getCentroid() 

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

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

710 for scalingFactor in error.errorDict: 

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

712 self.name) 

713 measRecord.set(flagName, True) 

714 for sigma in self.config._sigmas: 

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

716 self._setFlag(measRecord, baseName) 

717 else: 

718 measRecord.set(self._failKey, True) 

719 

720 

721class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

722 measBase.SingleFramePluginConfig): 

723 """Config for SingleFrameGaapFluxPlugin.""" 

724 

725 

726@measBase.register(PLUGIN_NAME) 

727class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

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

729 

730 Parameters 

731 ---------- 

732 config : `GaapFluxConfig` 

733 Plugin configuration. 

734 name : `str` 

735 Plugin name, for registering. 

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

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

738 to hold measurements produced by this plugin. 

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

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

741 logName : `str`, optional 

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

743 measurement framework. 

744 

745 Notes 

746 ----- 

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

748 the different bandpasses. 

749 """ 

750 ConfigClass = SingleFrameGaapFluxConfig 

751 

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

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

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

755 

756 @classmethod 

757 def getExecutionOrder(cls) -> float: 

758 # Docstring inherited 

759 return cls.FLUX_ORDER 

760 

761 def measure(self, measRecord, exposure): 

762 # Docstring inherited. 

763 center = measRecord.getCentroid() 

764 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

771 # Record the aperture used for optimal photometry 

772 measRecord.set(self.optimalShapeKey, optimalShape) 

773 self._gaussianizeAndMeasure(measRecord, exposure, center) 

774 

775 

776class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

777 """Config for ForcedGaapFluxPlugin.""" 

778 

779 

780@measBase.register(PLUGIN_NAME) 

781class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

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

783 

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

785 

786 Parameters 

787 ---------- 

788 config : `GaapFluxConfig` 

789 Plugin configuration. 

790 name : `str` 

791 Plugin name, for registering. 

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

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

794 Output fields will be added to the output schema. 

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

796 to hold measurements produced by this plugin. 

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

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

799 logName : `str`, optional 

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

801 measurement framework. 

802 """ 

803 ConfigClass = ForcedGaapFluxConfig 

804 

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

806 schema = schemaMapper.editOutputSchema() 

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

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

809 

810 @classmethod 

811 def getExecutionOrder(cls) -> float: 

812 # Docstring inherited. 

813 return cls.FLUX_ORDER 

814 

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

816 # Docstring inherited. 

817 wcs = exposure.getWcs() 

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

819 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

826 if not (wcs == refWcs): 

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

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

829 measFromRef = measFromSky*skyFromRef 

830 optimalShape.transformInPlace(measFromRef.getLinear()) 

831 # Record the intrinsic aperture used for optimal photometry. 

832 measRecord.set(self.optimalShapeKey, optimalShape) 

833 self._gaussianizeAndMeasure(measRecord, exposure, center)