Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 lsst.afw.detection as afwDetection 

32import lsst.afw.image as afwImage 

33import lsst.afw.geom as afwGeom 

34import lsst.afw.table as afwTable 

35import lsst.geom 

36import lsst.meas.base as measBase 

37from lsst.meas.base.fluxUtilities import FluxResultKey 

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.exceptions.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." 

118 ) 

119 

120 doOptimalPhotometry = pexConfig.Field( 

121 dtype=bool, 

122 default=True, 

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

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

125 ) 

126 

127 registerForApCorr = pexConfig.Field( 

128 dtype=bool, 

129 default=True, 

130 doc="Register measurements for aperture correction? " 

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

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

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

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

135 ) 

136 

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

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

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

140 @property 

141 def scaleByFwhm(self) -> bool: 

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

143 Scale kernelSize, alardGaussians by input Fwhm? 

144 """ 

145 return self._modelPsfMatch.kernel.active.scaleByFwhm 

146 

147 @scaleByFwhm.setter 

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

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

150 

151 @property 

152 def gaussianizationMethod(self) -> str: 

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

154 return self._modelPsfMatch.convolutionMethod 

155 

156 @gaussianizationMethod.setter 

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

158 self._modelPsfMatch.convolutionMethod = value 

159 

160 @property 

161 def _sigmas(self) -> list: 

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

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

164 """ 

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

166 

167 def setDefaults(self) -> None: 

168 # Docstring inherited 

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

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

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

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

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

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

175 self.scaleByFwhm = True 

176 

177 def validate(self): 

178 super().validate() 

179 self._modelPsfMatch.validate() 

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

181 

182 @staticmethod 

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

184 """Return the base name for GAaP fields 

185 

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

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

188 "ext_gaap_GaapFlux_1_15x_0_7". 

189 

190 Notes 

191 ----- 

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

193 to the input arguments. Instead, users should use 

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

195 

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

197 from GAaP measurements available outside the plugin. 

198 

199 Parameters 

200 ---------- 

201 scalingFactor : `float` 

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

203 sigma : `float` or `str` 

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

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

206 name : `str`, optional 

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

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

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

210 without the leading underscore is returned. 

211 

212 Returns 

213 ------- 

214 baseName : `str` 

215 Base name for GAaP field. 

216 """ 

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

218 if name is None: 

219 return suffix 

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

221 

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

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

224 

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

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

227 ("ext_gaap_GaapFlux_1_15x_0_7", "ext_gaap_GaapFlux_1_15x_1_0") when 

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

229 "ext_gaap_GaapFlux_1_15x_PsfFlux" if `doPsfPhotometry` is True. 

230 

231 Parameters 

232 ---------- 

233 name : `str`, optional 

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

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

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

237 for example) without the leading underscores are returned. 

238 

239 Returns 

240 ------- 

241 baseNames : `generator` 

242 A generator expression yielding all the base names. 

243 """ 

244 scalingFactors = self.scalingFactors 

245 sigmas = self._sigmas 

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

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

248 return baseNames 

249 

250 

251class BaseGaapFluxMixin: 

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

253 algorithm. 

254 

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

256 SingleFrameGaapFluxPlugin and ForcedGaapFluxPlugin which simply adapt it to 

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

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

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

260 

261 Parameters 

262 ---------- 

263 config : `BaseGaapFluxConfig` 

264 Plugin configuration. 

265 name : `str` 

266 Plugin name, for registering. 

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

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

269 to hold measurements produced by this plugin. 

270 

271 Raises 

272 ------ 

273 GaapConvolutionError 

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

275 lsst.meas.base.FatalAlgorithmError 

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

277 """ 

278 

279 ConfigClass = BaseGaapFluxConfig 

280 

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

282 # Flag definitions for each variant of GAaP measurement 

283 flagDefs = measBase.FlagDefinitionList() 

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

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

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

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

288 

289 # Remove the prefix_ since FlagHandler prepends it 

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

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

292 "bigger than the aperture") 

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

294 "parameters. ") 

295 

296 # PSF photometry 

297 if config.doPsfPhotometry: 

298 for scalingFactor in config.scalingFactors: 

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

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

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

302 

303 # Remove the prefix_ since FlagHandler prepends it 

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

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

306 "parameters. ") 

307 

308 if config.doOptimalPhotometry: 

309 # Add fields to hold the optimal aperture shape 

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

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

312 doc="Pre-seeing aperture used for " 

313 "optimal GAaP photometry") 

314 for scalingFactor in config.scalingFactors: 

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

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

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

318 

319 # Remove the prefix_ since FlagHandler prepends it 

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

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

322 "bigger than the aperture") 

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

324 "parameters. ") 

325 

326 if config.registerForApCorr: 

327 for baseName in config.getAllGaapResultNames(name): 

328 measBase.addApCorrName(baseName) 

329 

330 for scalingFactor in config.scalingFactors: 

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

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

333 

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

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

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

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

338 

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

340 

341 @staticmethod 

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

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

344 

345 Parameters 

346 ---------- 

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

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

349 

350 Returns 

351 ------- 

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

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

354 """ 

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

356 kernel.computeImage(kernelImage, False) 

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

358 acfImage = afwImage.ImageD(acfArray) 

359 return acfImage 

360 

361 @staticmethod 

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

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

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

365 to noise correlations. 

366 

367 This calculates the correction to apply to the naively computed 

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

369 in the PSF-Gaussianization step. 

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

371 

372 The returned value equals 

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

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

375 

376 Parameters 

377 ---------- 

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

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

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

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

382 measure GAaP flux. 

383 

384 Returns 

385 ------- 

386 fluxErrScaling : `float` 

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

388 """ 

389 aperShapeX2 = aperShape.convolve(aperShape) 

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

391 kernelAcf.getBBox().getCenter()) 

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

393 return fluxErrScaling 

394 

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

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

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

398 

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

400 and return the Gaussianized exposure in a struct. 

401 

402 Parameters 

403 ---------- 

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

405 Original (full) exposure containing all the sources. 

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

407 Target PSF to which to match. 

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

409 Record for the source to be measured. 

410 

411 Returns 

412 ------- 

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

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

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

416 containing the source, convolved to the target seeing and 

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

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

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

420 

421 Notes 

422 ----- 

423 During normal mode of operation, ``modelPsf`` is intended to be of the 

424 type `~lsst.afw.detection.GaussianPsf`, this is not enforced. 

425 """ 

426 footprint = measRecord.getFootprint() 

427 bbox = footprint.getBBox() 

428 

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

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

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

432 # initially by N pixels on either side. 

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

434 bbox.grow(pixToGrow) 

435 

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

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

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

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

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

441 bbox.clip(exposure.getBBox()) 

442 measRecord.setFlag(self.EdgeFlagKey, True) 

443 

444 subExposure = exposure[bbox] 

445 

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

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

448 targetPsfModel=modelPsf, 

449 basisSigmaGauss=[modelPsf.getSigma()]) 

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

451 # more Gaussian-like 

452 

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

454 # carefully later using _getFluxScaling method 

455 result.psfMatchedExposure.variance.array = subExposure.variance.array 

456 return result 

457 

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

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

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

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

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

463 

464 Parameters 

465 ---------- 

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

467 Catalog record for the source being measured. 

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

469 Subexposure containing the deblended source being measured. 

470 The PSF attached to it should nominally be an 

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

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

473 An image representating the auto-correlation function of the 

474 PSF-matching kernel. 

475 center : `~lsst.geom.Point2D` 

476 The centroid position of the source being measured. 

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

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

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

480 baseName : `str` 

481 The base name of the GAaP field. 

482 fluxScaling : `float`, optional 

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

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

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

486 """ 

487 if fluxScaling is None: 

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

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

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

491 try: 

492 aperShape.normalize() 

493 # Calculate the pre-seeing aperture. 

494 preseeingShape = aperShape.convolve(exposure.getPsf().computeShape()) 

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

496 except (InvalidParameterError, ZeroDivisionError): 

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

498 return 

499 

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

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

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

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

504 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

505 

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

507 aperShape, center) 

508 

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

510 fluxResult.instFlux *= fluxScaling 

511 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

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

513 fluxResultKey.set(measRecord, fluxResult) 

514 

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

516 exposure: afwImage.Exposure, 

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

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

519 

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

521 

522 Parameters 

523 ---------- 

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

525 Record describing the object being measured. Previously-measured 

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

527 in-place with the outputs of this plugin. 

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

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

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

531 by noise according to deblender outputs. 

532 center : `~lsst.geom.Point2D` 

533 Centroid location of the source being measured. 

534 

535 Raises 

536 ------ 

537 GaapConvolutionError 

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

539 lsst.meas.base.FatalAlgorithmError 

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

541 

542 Notes 

543 ----- 

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

545 classes. 

546 """ 

547 psf = exposure.getPsf() 

548 if psf is None: 

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

550 wcs = exposure.getWcs() 

551 

552 seeing = psf.computeShape(center).getTraceRadius() 

553 errorCollection = dict() 

554 for scalingFactor in self.config.scalingFactors: 

555 targetSigma = scalingFactor*seeing 

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

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

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

559 continue 

560 

561 stampSize = self.config._modelPsfDimension 

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

563 try: 

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

565 except Exception as error: 

566 errorCollection[str(scalingFactor)] = error 

567 continue 

568 

569 convolved = result.psfMatchedExposure 

570 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel) 

571 

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

573 psfShape = targetPsf.computeShape() # This is inexpensive for a GaussianPsf 

574 

575 if self.config.doPsfPhotometry: 

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

577 aperShape = psfShape 

578 measureFlux(aperShape, baseName, fluxScaling=1) 

579 

580 if self.config.doOptimalPhotometry: 

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

582 optimalShape = measRecord.get(self.optimalShapeKey) 

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

584 - psfShape.getParameterVector()) 

585 measureFlux(aperShape, baseName) 

586 

587 # Iterate over pre-defined circular apertures 

588 for sigma in self.config.sigmas: 

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

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

591 # Raise when the aperture is invalid 

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

593 continue 

594 

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

596 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

597 lsst.geom.arcseconds).getLinear()) 

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

599 - psfShape.getParameterVector()) 

600 measureFlux(aperShape, baseName) 

601 

602 # Raise GaapConvolutionError before exiting the plugin 

603 # if the collection of errors is not empty 

604 if errorCollection: 

605 raise GaapConvolutionError(errorCollection) 

606 

607 @staticmethod 

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

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

610 

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

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

613 To set the general plugin flag indicating measurement failure, 

614 use _failKey directly. 

615 

616 Parameters 

617 ---------- 

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

619 Record describing the source being measured. 

620 baseName : `str` 

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

622 flagName : `str`, optional 

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

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

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

626 """ 

627 if flagName is not None: 

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

629 measRecord.set(specificFlagKey, True) 

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

631 measRecord.set(genericFlagKey, True) 

632 

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

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

635 

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

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

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

639 and move on instead of spending computational effort in 

640 Gaussianizing the exposure. 

641 

642 Parameters 

643 ---------- 

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

645 Record describing the source being measured. 

646 scalingFactor : `float` 

647 The multiplicative factor by which the seeing is scaled. 

648 targetSigma : `float` 

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

650 

651 Returns 

652 ------- 

653 allFailure : `bool` 

654 A boolean value indicating whether all measurements would fail. 

655 

656 Notes 

657 ---- 

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

659 """ 

660 if self.config.doPsfPhotometry: 

661 return False 

662 

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

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

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

666 if self.config.doOptimalPhotometry and allFailure: 

667 optimalShape = measRecord.get(self.optimalShapeKey) 

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

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

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

671 

672 # Set all failure flags if allFailure is True. 

673 if allFailure: 

674 if self.config.doOptimalPhotometry: 

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

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

677 for sigma in self.config.sigmas: 

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

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

680 

681 return allFailure 

682 

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

684 """Record a measurement failure. 

685 

686 This default implementation simply records the failure in the source 

687 record and is inherited by the SingleFrameGaapFluxPlugin and 

688 ForcedGaapFluxPlugin. 

689 

690 Parameters 

691 ---------- 

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

693 Catalog record for the source being measured. 

694 error : `Exception` 

695 Error causing failure, or `None`. 

696 """ 

697 if error is not None: 

698 for scalingFactor in error.errorDict: 

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

700 self.name) 

701 measRecord.set(flagName, True) 

702 for sigma in self.config._sigmas: 

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

704 self._setFlag(measRecord, baseName) 

705 else: 

706 measRecord.set(self._failKey, True) 

707 

708 

709class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

710 measBase.SingleFramePluginConfig): 

711 """Config for SingleFrameGaapFluxPlugin.""" 

712 

713 

714@measBase.register(PLUGIN_NAME) 

715class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

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

717 

718 Parameters 

719 ---------- 

720 config : `GaapFluxConfig` 

721 Plugin configuration. 

722 name : `str` 

723 Plugin name, for registering. 

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

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

726 to hold measurements produced by this plugin. 

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

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

729 logName : `str`, optional 

730 Name to use when logging errors. 

731 

732 Notes 

733 ----- 

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

735 the different bandpasses. 

736 """ 

737 ConfigClass = SingleFrameGaapFluxConfig 

738 

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

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

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

742 

743 @classmethod 

744 def getExecutionOrder(cls) -> float: 

745 # Docstring inherited 

746 return cls.FLUX_ORDER 

747 

748 def measure(self, measRecord, exposure): 

749 # Docstring inherited. 

750 center = measRecord.getCentroid() 

751 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

758 # Record the aperture used for optimal photometry 

759 measRecord.set(self.optimalShapeKey, optimalShape) 

760 self._gaussianizeAndMeasure(measRecord, exposure, center) 

761 

762 

763class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

764 """Config for ForcedGaapFluxPlugin.""" 

765 

766 

767@measBase.register(PLUGIN_NAME) 

768class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

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

770 

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

772 

773 Parameters 

774 ---------- 

775 config : `GaapFluxConfig` 

776 Plugin configuration. 

777 name : `str` 

778 Plugin name, for registering. 

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

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

781 Output fields will be added to the output schema. 

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

783 to hold measurements produced by this plugin. 

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

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

786 logName : `str`, optional 

787 Name to use when logging errors. 

788 """ 

789 ConfigClass = ForcedGaapFluxConfig 

790 

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

792 schema = schemaMapper.editOutputSchema() 

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

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

795 

796 @classmethod 

797 def getExecutionOrder(cls) -> float: 

798 # Docstring inherited. 

799 return cls.FLUX_ORDER 

800 

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

802 # Docstring inherited. 

803 wcs = exposure.getWcs() 

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

805 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

812 if not (wcs == refWcs): 

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

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

815 measFromRef = measFromSky*skyFromRef 

816 optimalShape.transformInPlace(measFromRef.getLinear()) 

817 # Record the intrinsic aperture used for optimal photometry. 

818 measRecord.set(self.optimalShapeKey, optimalShape) 

819 self._gaussianizeAndMeasure(measRecord, exposure, center)