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

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/>.
23from __future__ import annotations
25__all__ = ("SingleFrameGaapFluxPlugin", "SingleFrameGaapFluxConfig",
26 "ForcedGaapFluxPlugin", "ForcedGaapFluxConfig")
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
44PLUGIN_NAME = "ext_gaap_GaapFlux"
47class GaapConvolutionError(measBase.exceptions.MeasurementError):
48 """Collection of any unexpected errors in GAaP during PSF Gaussianization.
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.
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.
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
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)
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 )
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 )
102 _modelPsfMatch = pexConfig.ConfigurableField(
103 target=GaussianizePsfTask,
104 doc="PSF Gaussianization Task"
105 )
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 )
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 )
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 )
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 )
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
149 @scaleByFwhm.setter
150 def scaleByFwhm(self, value: bool) -> None:
151 self._modelPsfMatch.kernel.active.scaleByFwhm = value
153 @property
154 def gaussianizationMethod(self) -> str:
155 """Type of convolution to use for PSF-Gaussianization."""
156 return self._modelPsfMatch.convolutionMethod
158 @gaussianizationMethod.setter
159 def gaussianizationMethod(self, value: str) -> None:
160 self._modelPsfMatch.convolutionMethod = value
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
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
179 def validate(self):
180 super().validate()
181 self._modelPsfMatch.validate()
182 assert self._modelPsfMatch.kernel.active.alardNGauss == 1
184 @staticmethod
185 def _getGaapResultName(scalingFactor: float, sigma: Union[float, str], name: Optional[str] = None) -> str:
186 """Return the base name for GAaP fields
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".
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.
198 This is not a config-y thing, but is placed here to make the fieldnames
199 from GAaP measurements available outside the plugin.
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.
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))
224 def getAllGaapResultNames(self, name: Optional[str] = PLUGIN_NAME) -> Generator[str]:
225 """Generate the base names for all of the GAaP fields.
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.
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.
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
253class BaseGaapFluxMixin:
254 """Mixin base class for Gaussian-Aperture and PSF (GAaP) photometry
255 algorithm.
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.
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.
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 """
284 ConfigClass = BaseGaapFluxConfig
285 hasLogName = True
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)
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. ")
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)
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. ")
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)
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. ")
332 if config.registerForApCorr:
333 for baseName in config.getAllGaapResultNames(name):
334 measBase.addApCorrName(baseName)
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.")
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")
346 self.psfMatchTask = config._modelPsfMatch.target(config=config._modelPsfMatch)
348 @staticmethod
349 def _computeKernelAcf(kernel: lsst.afw.math.Kernel) -> lsst.afw.image.Image: # noqa: F821
350 """Compute the auto-correlation function of ``kernel``.
352 Parameters
353 ----------
354 kernel : `~lsst.afw.math.Kernel`
355 The kernel for which auto-correlation function is to be computed.
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
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.
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).
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``.
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.
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
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.
406 Compute the convolution kernel to make the PSF same as ``modelPsf``
407 and return the Gaussianized exposure in a struct.
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.
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.
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()
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)
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)
451 subExposure = exposure[bbox]
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
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
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.
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
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)
513 fluxResult = measBase.SdssShapeAlgorithm.computeFixedMomentsFlux(exposure.getMaskedImage(),
514 aperShape, center)
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)
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.
527 The image may be from a single epoch, or it may be a coadd.
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.
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.
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()
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
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
576 convolved = result.psfMatchedExposure
577 kernelAcf = self._computeKernelAcf(result.psfMatchingKernel)
579 measureFlux = partial(self._measureFlux, measRecord, convolved, kernelAcf, center)
580 psfShape = targetPsf.computeShape() # This is inexpensive for a GaussianPsf
582 if self.config.doPsfPhotometry:
583 baseName = self.ConfigClass._getGaapResultName(scalingFactor, "PsfFlux", self.name)
584 aperShape = psfShape
585 measureFlux(aperShape, baseName, fluxScaling=1)
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)
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
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)
609 # Raise GaapConvolutionError before exiting the plugin
610 # if the collection of errors is not empty
611 if errorCollection:
612 raise GaapConvolutionError(errorCollection)
614 @staticmethod
615 def _setFlag(measRecord, baseName, flagName=None):
616 """Set the GAaP flag determined by ``baseName`` and ``flagName``.
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.
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)
640 def _isAllFailure(self, measRecord, scalingFactor, targetSigma) -> bool:
641 """Check if all measurements would result in failure.
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.
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.
658 Returns
659 -------
660 allFailure : `bool`
661 A boolean value indicating whether all measurements would fail.
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
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)
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")
688 return allFailure
690 def fail(self, measRecord, error=None):
691 """Record a measurement failure.
693 This default implementation simply records the failure in the source
694 record and is inherited by the SingleFrameGaapFluxPlugin and
695 ForcedGaapFluxPlugin.
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)
719class SingleFrameGaapFluxConfig(BaseGaapFluxConfig,
720 measBase.SingleFramePluginConfig):
721 """Config for SingleFrameGaapFluxPlugin."""
724@measBase.register(PLUGIN_NAME)
725class SingleFrameGaapFluxPlugin(BaseGaapFluxMixin, measBase.SingleFramePlugin):
726 """Gaussian Aperture and PSF photometry algorithm in single-frame mode.
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.
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
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)
754 @classmethod
755 def getExecutionOrder(cls) -> float:
756 # Docstring inherited
757 return cls.FLUX_ORDER
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)
774class ForcedGaapFluxConfig(BaseGaapFluxConfig, measBase.ForcedPluginConfig):
775 """Config for ForcedGaapFluxPlugin."""
778@measBase.register(PLUGIN_NAME)
779class ForcedGaapFluxPlugin(BaseGaapFluxMixin, measBase.ForcedPlugin):
780 """Gaussian Aperture and PSF (GAaP) photometry plugin in forced mode.
782 This is the GAaP plugin to run for consistent colors across the bandpasses.
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
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)
808 @classmethod
809 def getExecutionOrder(cls) -> float:
810 # Docstring inherited.
811 return cls.FLUX_ORDER
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)