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

274 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-05 19:49 +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 

38from lsst.meas.base.fluxUtilities import FluxResultKey 

39import lsst.pex.config as pexConfig 

40from lsst.pex.exceptions import InvalidParameterError 

41import scipy.signal 

42from ._gaussianizePsf import GaussianizePsfTask 

43 

44PLUGIN_NAME = "ext_gaap_GaapFlux" 

45 

46 

47class GaapConvolutionError(measBase.exceptions.MeasurementError): 

48 """Collection of any unexpected errors in GAaP during PSF Gaussianization. 

49 

50 The PSF Gaussianization procedure using `modelPsfMatchTask` may throw 

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

52 measurements are at least attempted. The complete traceback information 

53 is lost, but unique error messages are preserved. 

54 

55 Parameters 

56 ---------- 

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

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

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

60 """ 

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

62 self.errorDict = errors 

63 message = "Problematic scaling factors = " 

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

65 message += " Errors: " 

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

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

68 

69 

70class BaseGaapFluxConfig(measBase.BaseMeasurementPluginConfig): 

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

72 """ 

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

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

75 """ 

76 return x >= 1 

77 

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

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

80 """ 

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

82 

83 sigmas = pexConfig.ListField( 

84 dtype=float, 

85 default=[0.7, 1.0], 

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

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

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

89 ) 

90 

91 scalingFactors = pexConfig.ListField( 

92 dtype=float, 

93 default=[1.15], 

94 itemCheck=_greaterThanOrEqualToUnity, 

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

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

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

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

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

100 ) 

101 

102 _modelPsfMatch = pexConfig.ConfigurableField( 

103 target=GaussianizePsfTask, 

104 doc="PSF Gaussianization Task" 

105 ) 

106 

107 _modelPsfDimension = pexConfig.Field( 

108 dtype=int, 

109 default=65, 

110 check=_isOdd, 

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

112 ) 

113 

114 doPsfPhotometry = pexConfig.Field( 

115 dtype=bool, 

116 default=False, 

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

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

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

120 ) 

121 

122 doOptimalPhotometry = pexConfig.Field( 

123 dtype=bool, 

124 default=True, 

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

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

127 ) 

128 

129 registerForApCorr = pexConfig.Field( 

130 dtype=bool, 

131 default=True, 

132 doc="Register measurements for aperture correction? " 

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

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

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

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

137 ) 

138 

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

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

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

142 @property 

143 def scaleByFwhm(self) -> bool: 

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

145 Scale kernelSize, alardGaussians by input Fwhm? 

146 """ 

147 return self._modelPsfMatch.kernel.active.scaleByFwhm 

148 

149 @scaleByFwhm.setter 

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

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

152 

153 @property 

154 def gaussianizationMethod(self) -> str: 

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

156 return self._modelPsfMatch.convolutionMethod 

157 

158 @gaussianizationMethod.setter 

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

160 self._modelPsfMatch.convolutionMethod = value 

161 

162 @property 

163 def _sigmas(self) -> list: 

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

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

166 """ 

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

168 

169 def setDefaults(self) -> None: 

170 # Docstring inherited 

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

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

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

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

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

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

177 self.scaleByFwhm = True 

178 

179 def validate(self): 

180 super().validate() 

181 self._modelPsfMatch.validate() 

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

183 

184 @staticmethod 

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

186 """Return the base name for GAaP fields 

187 

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

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

190 "ext_gaap_GaapFlux_1_15x_0_7". 

191 

192 Notes 

193 ----- 

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

195 to the input arguments. Instead, users should use 

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

197 

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

199 from GAaP measurements available outside the plugin. 

200 

201 Parameters 

202 ---------- 

203 scalingFactor : `float` 

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

205 sigma : `float` or `str` 

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

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

208 name : `str`, optional 

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

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

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

212 without the leading underscore is returned. 

213 

214 Returns 

215 ------- 

216 baseName : `str` 

217 Base name for GAaP field. 

218 """ 

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

220 if name is None: 

221 return suffix 

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

223 

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

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

226 

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

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

229 ("ext_gaap_GaapFlux_1_15x_0_7", "ext_gaap_GaapFlux_1_15x_1_0") when 

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

231 "ext_gaap_GaapFlux_1_15x_PsfFlux" if `doPsfPhotometry` is True. 

232 

233 Parameters 

234 ---------- 

235 name : `str`, optional 

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

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

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

239 for example) without the leading underscores are returned. 

240 

241 Returns 

242 ------- 

243 baseNames : `generator` 

244 A generator expression yielding all the base names. 

245 """ 

246 scalingFactors = self.scalingFactors 

247 sigmas = self._sigmas 

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

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

250 return baseNames 

251 

252 

253class BaseGaapFluxMixin: 

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

255 algorithm. 

256 

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

258 SingleFrameGaapFluxPlugin and ForcedGaapFluxPlugin which simply adapt it to 

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

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

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

262 

263 Parameters 

264 ---------- 

265 config : `BaseGaapFluxConfig` 

266 Plugin configuration. 

267 name : `str` 

268 Plugin name, for registering. 

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

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

271 to hold measurements produced by this plugin. 

272 logName : `str`, optional 

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

274 measurement framework. 

275 

276 Raises 

277 ------ 

278 GaapConvolutionError 

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

280 lsst.meas.base.FatalAlgorithmError 

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

282 """ 

283 

284 ConfigClass = BaseGaapFluxConfig 

285 hasLogName = True 

286 

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

288 # Flag definitions for each variant of GAaP measurement 

289 flagDefs = measBase.FlagDefinitionList() 

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

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

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

293 FluxResultKey.addFields(schema, name=baseName, doc=doc) 

294 

295 # Remove the prefix_ since FlagHandler prepends it 

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

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

298 "bigger than the aperture") 

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

300 "parameters. ") 

301 

302 # PSF photometry 

303 if config.doPsfPhotometry: 

304 for scalingFactor in config.scalingFactors: 

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

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

307 FluxResultKey.addFields(schema, name=baseName, doc=doc) 

308 

309 # Remove the prefix_ since FlagHandler prepends it 

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

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

312 "parameters. ") 

313 

314 if config.doOptimalPhotometry: 

315 # Add fields to hold the optimal aperture shape 

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

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

318 doc="Pre-seeing aperture used for " 

319 "optimal GAaP photometry") 

320 for scalingFactor in config.scalingFactors: 

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

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

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

324 

325 # Remove the prefix_ since FlagHandler prepends it 

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

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

328 "bigger than the aperture") 

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

330 "parameters. ") 

331 

332 if config.registerForApCorr: 

333 for baseName in config.getAllGaapResultNames(name): 

334 measBase.addApCorrName(baseName) 

335 

336 for scalingFactor in config.scalingFactors: 

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

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

339 

340 self.log = logging.getLogger(logName) 

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

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

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

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

345 

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

347 

348 @staticmethod 

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

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

351 

352 Parameters 

353 ---------- 

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

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

356 

357 Returns 

358 ------- 

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

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

361 """ 

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

363 kernel.computeImage(kernelImage, False) 

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

365 acfImage = afwImage.ImageD(acfArray) 

366 return acfImage 

367 

368 @staticmethod 

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

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

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

372 to noise correlations. 

373 

374 This calculates the correction to apply to the naively computed 

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

376 in the PSF-Gaussianization step. 

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

378 

379 The returned value equals 

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

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

382 

383 Parameters 

384 ---------- 

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

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

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

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

389 measure GAaP flux. 

390 

391 Returns 

392 ------- 

393 fluxErrScaling : `float` 

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

395 """ 

396 aperShapeX2 = aperShape.convolve(aperShape) 

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

398 kernelAcf.getBBox().getCenter()) 

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

400 return fluxErrScaling 

401 

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

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

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

405 

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

407 and return the Gaussianized exposure in a struct. 

408 

409 Parameters 

410 ---------- 

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

412 Original (full) exposure containing all the sources. 

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

414 Target PSF to which to match. 

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

416 Record for the source to be measured. 

417 

418 Returns 

419 ------- 

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

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

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

423 containing the source, convolved to the target seeing and 

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

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

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

427 """ 

428 footprint = measRecord.getFootprint() 

429 bbox = footprint.getBBox() 

430 

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

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

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

434 # initially by N pixels on either side. 

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

436 bbox.grow(pixToGrow) 

437 

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

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

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

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

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

443 bbox.clip(exposure.getBBox()) 

444 measRecord.setFlag(self.EdgeFlagKey, True) 

445 

446 subExposure = exposure[bbox] 

447 

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

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

450 targetPsfModel=modelPsf, 

451 basisSigmaGauss=[modelPsf.getSigma()]) 

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

453 # more Gaussian-like 

454 

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

456 # carefully later using _getFluxScaling method 

457 result.psfMatchedExposure.variance.array = subExposure.variance.array 

458 return result 

459 

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

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

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

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

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

465 

466 Parameters 

467 ---------- 

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

469 Catalog record for the source being measured. 

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

471 Subexposure containing the deblended source being measured. 

472 The PSF attached to it should nominally be an 

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

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

475 An image representating the auto-correlation function of the 

476 PSF-matching kernel. 

477 center : `~lsst.geom.Point2D` 

478 The centroid position of the source being measured. 

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

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

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

482 baseName : `str` 

483 The base name of the GAaP field. 

484 fluxScaling : `float`, optional 

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

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

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

488 """ 

489 if fluxScaling is None: 

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

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

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

493 try: 

494 aperShape.normalize() 

495 # Calculate the pre-seeing aperture. 

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

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

498 except (InvalidParameterError, ZeroDivisionError): 

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

500 return 

501 

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

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

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

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

506 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

507 

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

509 aperShape, center) 

510 

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

512 fluxResult.instFlux *= fluxScaling 

513 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

514 fluxResultKey = FluxResultKey(measRecord.schema[baseName]) 

515 fluxResultKey.set(measRecord, fluxResult) 

516 

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

518 exposure: afwImage.Exposure, 

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

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

521 

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

523 

524 Parameters 

525 ---------- 

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

527 Record describing the object being measured. Previously-measured 

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

529 in-place with the outputs of this plugin. 

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

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

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

533 by noise according to deblender outputs. 

534 center : `~lsst.geom.Point2D` 

535 Centroid location of the source being measured. 

536 

537 Raises 

538 ------ 

539 GaapConvolutionError 

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

541 lsst.meas.base.FatalAlgorithmError 

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

543 

544 Notes 

545 ----- 

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

547 classes. 

548 """ 

549 psf = exposure.getPsf() 

550 if psf is None: 

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

552 wcs = exposure.getWcs() 

553 

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

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

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

557 for scalingFactor in self.config.scalingFactor} 

558 raise GaapConvolutionError(errorCollection) 

559 else: 

560 errorCollection = dict() 

561 

562 for scalingFactor in self.config.scalingFactors: 

563 targetSigma = scalingFactor*psfSigma 

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

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

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

567 continue 

568 

569 stampSize = self.config._modelPsfDimension 

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

571 try: 

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

573 except Exception as error: 

574 errorCollection[str(scalingFactor)] = error 

575 continue 

576 

577 convolved = result.psfMatchedExposure 

578 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel) 

579 

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

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

582 # GaussianPsf 

583 psfShape = targetPsf.computeShape(center) 

584 

585 if self.config.doPsfPhotometry: 

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

587 aperShape = psfShape 

588 measureFlux(aperShape, baseName, fluxScaling=1) 

589 

590 if self.config.doOptimalPhotometry: 

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

592 optimalShape = measRecord.get(self.optimalShapeKey) 

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

594 - psfShape.getParameterVector()) 

595 measureFlux(aperShape, baseName) 

596 

597 # Iterate over pre-defined circular apertures 

598 for sigma in self.config.sigmas: 

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

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

601 # Raise when the aperture is invalid 

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

603 continue 

604 

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

606 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

607 lsst.geom.arcseconds).getLinear()) 

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

609 - psfShape.getParameterVector()) 

610 measureFlux(aperShape, baseName) 

611 

612 # Raise GaapConvolutionError before exiting the plugin 

613 # if the collection of errors is not empty 

614 if errorCollection: 

615 raise GaapConvolutionError(errorCollection) 

616 

617 @staticmethod 

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

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

620 

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

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

623 To set the general plugin flag indicating measurement failure, 

624 use _failKey directly. 

625 

626 Parameters 

627 ---------- 

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

629 Record describing the source being measured. 

630 baseName : `str` 

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

632 flagName : `str`, optional 

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

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

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

636 """ 

637 if flagName is not None: 

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

639 measRecord.set(specificFlagKey, True) 

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

641 measRecord.set(genericFlagKey, True) 

642 

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

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

645 

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

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

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

649 and move on instead of spending computational effort in 

650 Gaussianizing the exposure. 

651 

652 Parameters 

653 ---------- 

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

655 Record describing the source being measured. 

656 scalingFactor : `float` 

657 The multiplicative factor by which the seeing is scaled. 

658 targetSigma : `float` 

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

660 

661 Returns 

662 ------- 

663 allFailure : `bool` 

664 A boolean value indicating whether all measurements would fail. 

665 

666 Notes 

667 ---- 

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

669 """ 

670 if self.config.doPsfPhotometry: 

671 return False 

672 

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

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

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

676 if self.config.doOptimalPhotometry and allFailure: 

677 optimalShape = measRecord.get(self.optimalShapeKey) 

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

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

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

681 

682 # Set all failure flags if allFailure is True. 

683 if allFailure: 

684 if self.config.doOptimalPhotometry: 

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

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

687 for sigma in self.config.sigmas: 

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

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

690 

691 return allFailure 

692 

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

694 """Record a measurement failure. 

695 

696 This default implementation simply records the failure in the source 

697 record and is inherited by the SingleFrameGaapFluxPlugin and 

698 ForcedGaapFluxPlugin. 

699 

700 Parameters 

701 ---------- 

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

703 Catalog record for the source being measured. 

704 error : `Exception` 

705 Error causing failure, or `None`. 

706 """ 

707 if error is not None: 

708 center = measRecord.getCentroid() 

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

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

711 for scalingFactor in error.errorDict: 

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

713 self.name) 

714 measRecord.set(flagName, True) 

715 for sigma in self.config._sigmas: 

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

717 self._setFlag(measRecord, baseName) 

718 else: 

719 measRecord.set(self._failKey, True) 

720 

721 

722class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

723 measBase.SingleFramePluginConfig): 

724 """Config for SingleFrameGaapFluxPlugin.""" 

725 

726 

727@measBase.register(PLUGIN_NAME) 

728class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

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

730 

731 Parameters 

732 ---------- 

733 config : `GaapFluxConfig` 

734 Plugin configuration. 

735 name : `str` 

736 Plugin name, for registering. 

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

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

739 to hold measurements produced by this plugin. 

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

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

742 logName : `str`, optional 

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

744 measurement framework. 

745 

746 Notes 

747 ----- 

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

749 the different bandpasses. 

750 """ 

751 ConfigClass = SingleFrameGaapFluxConfig 

752 

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

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

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

756 

757 @classmethod 

758 def getExecutionOrder(cls) -> float: 

759 # Docstring inherited 

760 return cls.FLUX_ORDER 

761 

762 def measure(self, measRecord, exposure): 

763 # Docstring inherited. 

764 center = measRecord.getCentroid() 

765 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

772 # Record the aperture used for optimal photometry 

773 measRecord.set(self.optimalShapeKey, optimalShape) 

774 self._gaussianizeAndMeasure(measRecord, exposure, center) 

775 

776 

777class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

778 """Config for ForcedGaapFluxPlugin.""" 

779 

780 

781@measBase.register(PLUGIN_NAME) 

782class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

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

784 

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

786 

787 Parameters 

788 ---------- 

789 config : `GaapFluxConfig` 

790 Plugin configuration. 

791 name : `str` 

792 Plugin name, for registering. 

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

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

795 Output fields will be added to the output schema. 

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

797 to hold measurements produced by this plugin. 

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

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

800 logName : `str`, optional 

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

802 measurement framework. 

803 """ 

804 ConfigClass = ForcedGaapFluxConfig 

805 

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

807 schema = schemaMapper.editOutputSchema() 

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

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

810 

811 @classmethod 

812 def getExecutionOrder(cls) -> float: 

813 # Docstring inherited. 

814 return cls.FLUX_ORDER 

815 

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

817 # Docstring inherited. 

818 wcs = exposure.getWcs() 

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

820 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

827 if not (wcs == refWcs): 

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

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

830 measFromRef = measFromSky*skyFromRef 

831 optimalShape.transformInPlace(measFromRef.getLinear()) 

832 # Record the intrinsic aperture used for optimal photometry. 

833 measRecord.set(self.optimalShapeKey, optimalShape) 

834 self._gaussianizeAndMeasure(measRecord, exposure, center)