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 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 Notes 

429 ----- 

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

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

432 """ 

433 footprint = measRecord.getFootprint() 

434 bbox = footprint.getBBox() 

435 

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

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

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

439 # initially by N pixels on either side. 

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

441 bbox.grow(pixToGrow) 

442 

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

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

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

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

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

448 bbox.clip(exposure.getBBox()) 

449 measRecord.setFlag(self.EdgeFlagKey, True) 

450 

451 subExposure = exposure[bbox] 

452 

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

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

455 targetPsfModel=modelPsf, 

456 basisSigmaGauss=[modelPsf.getSigma()]) 

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

458 # more Gaussian-like 

459 

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

461 # carefully later using _getFluxScaling method 

462 result.psfMatchedExposure.variance.array = subExposure.variance.array 

463 return result 

464 

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

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

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

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

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

470 

471 Parameters 

472 ---------- 

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

474 Catalog record for the source being measured. 

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

476 Subexposure containing the deblended source being measured. 

477 The PSF attached to it should nominally be an 

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

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

480 An image representating the auto-correlation function of the 

481 PSF-matching kernel. 

482 center : `~lsst.geom.Point2D` 

483 The centroid position of the source being measured. 

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

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

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

487 baseName : `str` 

488 The base name of the GAaP field. 

489 fluxScaling : `float`, optional 

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

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

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

493 """ 

494 if fluxScaling is None: 

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

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

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

498 try: 

499 aperShape.normalize() 

500 # Calculate the pre-seeing aperture. 

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

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

503 except (InvalidParameterError, ZeroDivisionError): 

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

505 return 

506 

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

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

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

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

511 fluxErrScaling = self._getFluxErrScaling(kernelAcf, aperShape) 

512 

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

514 aperShape, center) 

515 

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

517 fluxResult.instFlux *= fluxScaling 

518 fluxResult.instFluxErr *= fluxScaling*fluxErrScaling 

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

520 fluxResultKey.set(measRecord, fluxResult) 

521 

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

523 exposure: afwImage.Exposure, 

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

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

526 

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

528 

529 Parameters 

530 ---------- 

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

532 Record describing the object being measured. Previously-measured 

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

534 in-place with the outputs of this plugin. 

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

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

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

538 by noise according to deblender outputs. 

539 center : `~lsst.geom.Point2D` 

540 Centroid location of the source being measured. 

541 

542 Raises 

543 ------ 

544 GaapConvolutionError 

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

546 lsst.meas.base.FatalAlgorithmError 

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

548 

549 Notes 

550 ----- 

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

552 classes. 

553 """ 

554 psf = exposure.getPsf() 

555 if psf is None: 

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

557 wcs = exposure.getWcs() 

558 

559 psfSigma = psf.computeShape(center).getDeterminantRadius() 

560 errorCollection = dict() 

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 psfShape = targetPsf.computeShape() # This is inexpensive for a GaussianPsf 

581 

582 if self.config.doPsfPhotometry: 

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

584 aperShape = psfShape 

585 measureFlux(aperShape, baseName, fluxScaling=1) 

586 

587 if self.config.doOptimalPhotometry: 

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

589 optimalShape = measRecord.get(self.optimalShapeKey) 

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

591 - psfShape.getParameterVector()) 

592 measureFlux(aperShape, baseName) 

593 

594 # Iterate over pre-defined circular apertures 

595 for sigma in self.config.sigmas: 

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

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

598 # Raise when the aperture is invalid 

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

600 continue 

601 

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

603 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, 

604 lsst.geom.arcseconds).getLinear()) 

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

606 - psfShape.getParameterVector()) 

607 measureFlux(aperShape, baseName) 

608 

609 # Raise GaapConvolutionError before exiting the plugin 

610 # if the collection of errors is not empty 

611 if errorCollection: 

612 raise GaapConvolutionError(errorCollection) 

613 

614 @staticmethod 

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

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

617 

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

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

620 To set the general plugin flag indicating measurement failure, 

621 use _failKey directly. 

622 

623 Parameters 

624 ---------- 

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

626 Record describing the source being measured. 

627 baseName : `str` 

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

629 flagName : `str`, optional 

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

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

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

633 """ 

634 if flagName is not None: 

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

636 measRecord.set(specificFlagKey, True) 

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

638 measRecord.set(genericFlagKey, True) 

639 

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

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

642 

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

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

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

646 and move on instead of spending computational effort in 

647 Gaussianizing the exposure. 

648 

649 Parameters 

650 ---------- 

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

652 Record describing the source being measured. 

653 scalingFactor : `float` 

654 The multiplicative factor by which the seeing is scaled. 

655 targetSigma : `float` 

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

657 

658 Returns 

659 ------- 

660 allFailure : `bool` 

661 A boolean value indicating whether all measurements would fail. 

662 

663 Notes 

664 ---- 

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

666 """ 

667 if self.config.doPsfPhotometry: 

668 return False 

669 

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

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

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

673 if self.config.doOptimalPhotometry and allFailure: 

674 optimalShape = measRecord.get(self.optimalShapeKey) 

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

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

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

678 

679 # Set all failure flags if allFailure is True. 

680 if allFailure: 

681 if self.config.doOptimalPhotometry: 

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

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

684 for sigma in self.config.sigmas: 

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

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

687 

688 return allFailure 

689 

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

691 """Record a measurement failure. 

692 

693 This default implementation simply records the failure in the source 

694 record and is inherited by the SingleFrameGaapFluxPlugin and 

695 ForcedGaapFluxPlugin. 

696 

697 Parameters 

698 ---------- 

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

700 Catalog record for the source being measured. 

701 error : `Exception` 

702 Error causing failure, or `None`. 

703 """ 

704 if error is not None: 

705 center = measRecord.getCentroid() 

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

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

708 for scalingFactor in error.errorDict: 

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

710 self.name) 

711 measRecord.set(flagName, True) 

712 for sigma in self.config._sigmas: 

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

714 self._setFlag(measRecord, baseName) 

715 else: 

716 measRecord.set(self._failKey, True) 

717 

718 

719class SingleFrameGaapFluxConfig(BaseGaapFluxConfig, 

720 measBase.SingleFramePluginConfig): 

721 """Config for SingleFrameGaapFluxPlugin.""" 

722 

723 

724@measBase.register(PLUGIN_NAME) 

725class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin): 

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

727 

728 Parameters 

729 ---------- 

730 config : `GaapFluxConfig` 

731 Plugin configuration. 

732 name : `str` 

733 Plugin name, for registering. 

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

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

736 to hold measurements produced by this plugin. 

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

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

739 logName : `str`, optional 

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

741 measurement framework. 

742 

743 Notes 

744 ----- 

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

746 the different bandpasses. 

747 """ 

748 ConfigClass = SingleFrameGaapFluxConfig 

749 

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

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

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

753 

754 @classmethod 

755 def getExecutionOrder(cls) -> float: 

756 # Docstring inherited 

757 return cls.FLUX_ORDER 

758 

759 def measure(self, measRecord, exposure): 

760 # Docstring inherited. 

761 center = measRecord.getCentroid() 

762 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

769 # Record the aperture used for optimal photometry 

770 measRecord.set(self.optimalShapeKey, optimalShape) 

771 self._gaussianizeAndMeasure(measRecord, exposure, center) 

772 

773 

774class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig): 

775 """Config for ForcedGaapFluxPlugin.""" 

776 

777 

778@measBase.register(PLUGIN_NAME) 

779class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin): 

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

781 

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

783 

784 Parameters 

785 ---------- 

786 config : `GaapFluxConfig` 

787 Plugin configuration. 

788 name : `str` 

789 Plugin name, for registering. 

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

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

792 Output fields will be added to the output schema. 

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

794 to hold measurements produced by this plugin. 

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

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

797 logName : `str`, optional 

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

799 measurement framework. 

800 """ 

801 ConfigClass = ForcedGaapFluxConfig 

802 

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

804 schema = schemaMapper.editOutputSchema() 

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

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

807 

808 @classmethod 

809 def getExecutionOrder(cls) -> float: 

810 # Docstring inherited. 

811 return cls.FLUX_ORDER 

812 

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

814 # Docstring inherited. 

815 wcs = exposure.getWcs() 

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

817 if self.config.doOptimalPhotometry: 

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

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

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

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

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

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

824 if not (wcs == refWcs): 

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

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

827 measFromRef = measFromSky*skyFromRef 

828 optimalShape.transformInPlace(measFromRef.getLinear()) 

829 # Record the intrinsic aperture used for optimal photometry. 

830 measRecord.set(self.optimalShapeKey, optimalShape) 

831 self._gaussianizeAndMeasure(measRecord, exposure, center)