Coverage for tests/test_gaap.py: 12%
354 statements
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-12 03:28 -0700
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-12 03:28 -0700
1# This file is part of meas_extensions_gaap
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org/).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <http://www.lsstcorp.org/LegalNotices/>.
23import math
24import unittest
25import galsim
26import itertools
27import lsst.afw.detection as afwDetection
28import lsst.afw.display as afwDisplay
29import lsst.afw.geom as afwGeom
30import lsst.afw.image as afwImage
31import lsst.afw.table as afwTable
32import lsst.daf.base as dafBase
33import lsst.geom as geom
34from lsst.pex.exceptions import InvalidParameterError
35import lsst.meas.base as measBase
36import lsst.meas.base.tests
37import lsst.meas.extensions.gaap
38import lsst.utils.tests
39import numpy as np
40import scipy
43try:
44 type(display)
45except NameError:
46 display = False
47 frame = 1
50def makeGalaxyExposure(scale, psfSigma=0.9, flux=1000., galSigma=3.7, variance=1.0):
51 """Make an ideal exposure of circular Gaussian
53 For the purpose of testing Gaussian Aperture and PSF algorithm (GAaP), this
54 generates a noiseless image of circular Gaussian galaxy of a desired total
55 flux convolved by a circular Gaussian PSF. The Gaussianity of the galaxy
56 and the PSF allows comparison with analytical results, modulo pixelization.
58 Parameters
59 ----------
60 scale : `float`
61 Pixel scale in the exposure.
62 psfSigma : `float`
63 Sigma of the circular Gaussian PSF.
64 flux : `float`
65 The total flux of the galaxy.
66 galSigma : `float`
67 Sigma of the pre-seeing circular Gaussian galaxy.
69 Returns
70 -------
71 exposure, center
72 A tuple containing an lsst.afw.image.Exposure and lsst.geom.Point2D
73 objects, corresponding to the galaxy image and its centroid.
74 """
75 psfWidth = 2*int(4.0*psfSigma) + 1
76 galWidth = 2*int(40.*math.hypot(galSigma, psfSigma)) + 1
77 gal = galsim.Gaussian(sigma=galSigma, flux=flux)
79 galIm = galsim.Image(galWidth, galWidth)
80 galIm = galsim.Convolve([gal, galsim.Gaussian(sigma=psfSigma, flux=1.)]).drawImage(image=galIm,
81 scale=1.0,
82 method='no_pixel')
83 exposure = afwImage.makeExposure(afwImage.makeMaskedImageFromArrays(galIm.array))
84 exposure.setPsf(afwDetection.GaussianPsf(psfWidth, psfWidth, psfSigma))
86 exposure.variance.set(variance)
87 exposure.mask.set(0)
88 center = exposure.getBBox().getCenter()
90 cdMatrix = afwGeom.makeCdMatrix(scale=scale)
91 exposure.setWcs(afwGeom.makeSkyWcs(crpix=center,
92 crval=geom.SpherePoint(0.0, 0.0, geom.degrees),
93 cdMatrix=cdMatrix))
94 return exposure, center
97class GaapFluxTestCase(lsst.meas.base.tests.AlgorithmTestCase, lsst.utils.tests.TestCase):
98 """Main test case for the GAaP plugin.
99 """
100 def setUp(self):
101 self.center = lsst.geom.Point2D(100.0, 770.0)
102 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(-20, -30),
103 lsst.geom.Extent2I(240, 1600))
104 self.dataset = lsst.meas.base.tests.TestDataset(self.bbox)
106 # We will consider three sources in our test case
107 # recordId = 0: A bright point source
108 # recordId = 1: An elliptical (Gaussian) galaxy
109 # recordId = 2: A source near a corner
110 self.dataset.addSource(1000., self.center - lsst.geom.Extent2I(0, 100))
111 self.dataset.addSource(1000., self.center + lsst.geom.Extent2I(0, 100),
112 afwGeom.Quadrupole(9., 9., 4.))
113 self.dataset.addSource(600., lsst.geom.Point2D(self.bbox.getMin()) + lsst.geom.Extent2I(10, 10))
115 def tearDown(self):
116 del self.center
117 del self.bbox
118 del self.dataset
120 def makeAlgorithm(self, gaapConfig=None):
121 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
122 if gaapConfig is None:
123 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig()
124 gaapPlugin = lsst.meas.extensions.gaap.SingleFrameGaapFluxPlugin(gaapConfig,
125 "ext_gaap_GaapFlux",
126 schema, None)
127 if gaapConfig.doOptimalPhotometry:
128 afwTable.QuadrupoleKey.addFields(schema, "psfShape", "PSF shape")
129 schema.getAliasMap().set("slot_PsfShape", "psfShape")
130 return gaapPlugin, schema
132 def check(self, psfSigma=0.5, flux=1000., scalingFactors=[1.15], forced=False):
133 """Check for non-negative values for GAaP instFlux and instFluxErr.
134 """
135 scale = 0.1*geom.arcseconds
137 TaskClass = measBase.ForcedMeasurementTask if forced else measBase.SingleFrameMeasurementTask
139 # Create an image of a tiny source
140 exposure, center = makeGalaxyExposure(scale, psfSigma, flux, galSigma=0.001, variance=0.)
142 measConfig = TaskClass.ConfigClass()
143 algName = "ext_gaap_GaapFlux"
145 measConfig.plugins.names.add(algName)
147 if forced:
148 measConfig.copyColumns = {"id": "objectId", "parent": "parentObjectId"}
150 algConfig = measConfig.plugins[algName]
151 algConfig.scalingFactors = scalingFactors
152 algConfig.scaleByFwhm = True
153 algConfig.doPsfPhotometry = True
154 # Do not turn on optimal photometry; not robust for a point-source.
155 algConfig.doOptimalPhotometry = False
157 if forced:
158 offset = geom.Extent2D(-12.3, 45.6)
159 refWcs = exposure.getWcs().copyAtShiftedPixelOrigin(offset)
160 refSchema = afwTable.SourceTable.makeMinimalSchema()
161 centroidKey = afwTable.Point2DKey.addFields(refSchema, "my_centroid", doc="centroid",
162 unit="pixel")
163 shapeKey = afwTable.QuadrupoleKey.addFields(refSchema, "my_shape", "shape")
164 refSchema.getAliasMap().set("slot_Centroid", "my_centroid")
165 refSchema.getAliasMap().set("slot_Shape", "my_shape")
166 refSchema.addField("my_centroid_flag", type="Flag", doc="centroid flag")
167 refSchema.addField("my_shape_flag", type="Flag", doc="shape flag")
168 refCat = afwTable.SourceCatalog(refSchema)
169 refSource = refCat.addNew()
170 refSource.set(centroidKey, center + offset)
171 refSource.set(shapeKey, afwGeom.Quadrupole(1.0, 1.0, 0.0))
173 refSource.setCoord(refWcs.pixelToSky(refSource.get(centroidKey)))
174 taskInitArgs = (refSchema,)
175 taskRunArgs = (refCat, refWcs)
176 else:
177 taskInitArgs = (afwTable.SourceTable.makeMinimalSchema(),)
178 taskRunArgs = ()
180 # Activate undeblended measurement with the same configuration
181 measConfig.undeblended.names.add(algName)
182 measConfig.undeblended[algName] = measConfig.plugins[algName]
184 # We are no longer going to change the configs.
185 # So validate and freeze as they would happen when run from a CLI
186 measConfig.validate()
187 measConfig.freeze()
189 algMetadata = dafBase.PropertyList()
190 task = TaskClass(*taskInitArgs, config=measConfig, algMetadata=algMetadata)
192 schema = task.schema
193 measCat = afwTable.SourceCatalog(schema)
194 source = measCat.addNew()
195 source.getTable().setMetadata(algMetadata)
196 ss = afwDetection.FootprintSet(exposure.getMaskedImage(), afwDetection.Threshold(10.0))
197 fp = ss.getFootprints()[0]
198 source.setFootprint(fp)
200 task.run(measCat, exposure, *taskRunArgs)
202 if display:
203 disp = afwDisplay.Display(frame)
204 disp.mtv(exposure)
205 disp.dot("x", *center, origin=afwImage.PARENT, title="psfSigma=%f" % (psfSigma,))
207 self.assertFalse(source.get(algName + "_flag")) # algorithm succeeded
209 # We first check if it produces a positive number (non-nan)
210 for baseName in algConfig.getAllGaapResultNames(algName):
211 self.assertTrue((source.get(baseName + "_instFlux") >= 0))
212 self.assertTrue((source.get(baseName + "_instFluxErr") >= 0))
214 # For scalingFactor > 1, check if the measured value is close to truth.
215 for baseName in algConfig.getAllGaapResultNames(algName):
216 if "_1_0x_" not in baseName:
217 rtol = 0.1 if "PsfFlux" not in baseName else 0.2
218 self.assertFloatsAlmostEqual(source.get(baseName + "_instFlux"), flux, rtol=rtol)
220 def runGaap(self, forced, psfSigma, scalingFactors=(1.0, 1.05, 1.1, 1.15, 1.2, 1.5, 2.0)):
221 self.check(psfSigma=psfSigma, forced=forced, scalingFactors=scalingFactors)
223 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,))
224 def testGaapPluginUnforced(self, psfSigma):
225 """Run GAaP as Single-frame measurement plugin.
226 """
227 self.runGaap(False, psfSigma)
229 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,))
230 def testGaapPluginForced(self, psfSigma):
231 """Run GAaP as forced measurement plugin.
232 """
233 self.runGaap(True, psfSigma)
235 def testFail(self, scalingFactors=[100.], sigmas=[500.]):
236 """Test that the fail method sets the flags correctly.
238 Set config parameters that are guaranteed to raise exceptions,
239 and check that they are handled properly by the `fail` method and that
240 expected log messages are generated.
241 For failure modes not handled by the `fail` method, we test them
242 in the ``testFlags`` method.
243 """
244 algName = "ext_gaap_GaapFlux"
245 dependencies = ("base_SdssShape",)
246 config = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies)
247 gaapConfig = config.plugins[algName]
248 gaapConfig.scalingFactors = scalingFactors
249 gaapConfig.sigmas = sigmas
250 gaapConfig.doPsfPhotometry = True
251 gaapConfig.doOptimalPhotometry = True
253 gaapConfig.scaleByFwhm = True
254 self.assertTrue(gaapConfig.scaleByFwhm) # Test the getter method.
256 algMetadata = lsst.daf.base.PropertyList()
257 sfmTask = self.makeSingleFrameMeasurementTask(algName, dependencies=dependencies, config=config,
258 algMetadata=algMetadata)
259 exposure, catalog = self.dataset.realize(0.0, sfmTask.schema)
260 self.recordPsfShape(catalog)
262 # Expected error messages in the logs when running `sfmTask`.
263 errorMessage = [("ERROR:measurement.ext_gaap_GaapFlux:"
264 "Failed to solve for PSF matching kernel in GAaP for (100.000000, 670.000000): "
265 "Problematic scaling factors = 100.0 "
266 "Errors: Exception('Unable to determine kernel sum; 0 candidates')"),
267 ("ERROR:measurement.ext_gaap_GaapFlux:"
268 "Failed to solve for PSF matching kernel in GAaP for (100.000000, 870.000000): "
269 "Problematic scaling factors = 100.0 "
270 "Errors: Exception('Unable to determine kernel sum; 0 candidates')"),
271 ("ERROR:measurement.ext_gaap_GaapFlux:"
272 "Failed to solve for PSF matching kernel in GAaP for (-10.000000, -20.000000): "
273 "Problematic scaling factors = 100.0 "
274 "Errors: Exception('Unable to determine kernel sum; 0 candidates')")]
276 with self.assertLogs(sfmTask.log.name, "ERROR") as cm:
277 sfmTask.run(catalog, exposure)
278 self.assertEqual(cm.output, errorMessage)
280 for record in catalog:
281 self.assertFalse(record[algName + "_flag"])
282 for scalingFactor in scalingFactors:
283 flagName = gaapConfig._getGaapResultName(scalingFactor, "flag_gaussianization", algName)
284 self.assertTrue(record[flagName])
285 for sigma in sigmas + ["Optimal"]:
286 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
287 self.assertTrue(record[baseName + "_flag"])
288 self.assertFalse(record[baseName + "_flag_bigPsf"])
290 baseName = gaapConfig._getGaapResultName(scalingFactor, "PsfFlux", algName)
291 self.assertTrue(record[baseName + "_flag"])
293 # Try and "fail" with no PSF.
294 # Since fatal exceptions are not caught by the measurement framework,
295 # use a context manager and catch it here.
296 exposure.setPsf(None)
297 with self.assertRaises(lsst.meas.base.FatalAlgorithmError):
298 sfmTask.run(catalog, exposure)
300 def testFlags(self, sigmas=[0.4, 0.5, 0.7], scalingFactors=[1.15, 1.25, 1.4, 100.]):
301 """Test that GAaP flags are set properly.
303 Specifically, we test that
305 1. for invalid combinations of config parameters, only the
306 appropriate flags are set and not that the entire measurement itself is
307 flagged.
308 2. for sources close to the edge, the edge flags are set.
310 Parameters
311 ----------
312 sigmas : `list` [`float`], optional
313 The list of sigmas (in arcseconds) to construct the
314 `SingleFrameGaapFluxConfig`.
315 scalingFactors : `list` [`float`], optional
316 The list of scaling factors to construct the
317 `SingleFrameGaapFluxConfig`.
319 Raises
320 -----
321 InvalidParameterError
322 Raised if none of the config parameters will fail a measurement.
324 Notes
325 -----
326 Since the seeing in the test dataset is 2 pixels, at least one of the
327 ``sigmas`` should be smaller than at least twice of one of the
328 ``scalingFactors`` to avoid the InvalidParameterError exception being
329 raised.
330 """
331 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
332 scalingFactors=scalingFactors)
333 gaapConfig.scaleByFwhm = True
334 gaapConfig.doOptimalPhotometry = True
336 # Make an instance of GAaP algorithm from a config
337 algName = "ext_gaap_GaapFlux"
338 algorithm, schema = self.makeAlgorithm(gaapConfig)
339 # Make a noiseless exposure and measurements for reference
340 exposure, catalog = self.dataset.realize(0.0, schema)
341 # Record the PSF shapes if optimal photometry is performed.
342 if gaapConfig.doOptimalPhotometry:
343 self.recordPsfShape(catalog)
345 record = catalog[0]
346 algorithm.measure(record, exposure)
347 seeing = exposure.getPsf().getSigma()
348 pixelScale = exposure.getWcs().getPixelScale().asArcseconds()
349 # Measurement must fail (i.e., flag_bigPsf and flag must be set) if
350 # sigma < scalingFactor * seeing
351 # Ensure that there is at least one combination of parameters that fail
352 if not(min(gaapConfig.sigmas)/pixelScale < seeing*max(gaapConfig.scalingFactors)):
353 raise InvalidParameterError("The config parameters do not trigger a measurement failure. "
354 "Consider including lower values in ``sigmas`` and/or larger values "
355 "for ``scalingFactors``")
356 # Ensure that the measurement is not a complete failure
357 self.assertFalse(record[algName + "_flag"])
358 self.assertFalse(record[algName + "_flag_edge"])
359 # Ensure that flag_bigPsf is set if sigma < scalingFactor * seeing
360 for scalingFactor, sigma in itertools.product(gaapConfig.scalingFactors, gaapConfig.sigmas):
361 targetSigma = scalingFactor*seeing
362 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
363 # Give some leeway for the edge case.
364 if targetSigma - sigma/pixelScale >= -1e-10:
365 self.assertTrue(record[baseName+"_flag_bigPsf"])
366 self.assertTrue(record[baseName+"_flag"])
367 else:
368 self.assertFalse(record[baseName+"_flag_bigPsf"])
369 self.assertFalse(record[baseName+"_flag"])
371 # Ensure that flag_bigPsf is set if OptimalShape is not large enough.
372 if gaapConfig.doOptimalPhotometry:
373 aperShape = afwTable.QuadrupoleKey(schema[schema.join(algName, "OptimalShape")]).get(record)
374 for scalingFactor in gaapConfig.scalingFactors:
375 targetSigma = scalingFactor*seeing
376 baseName = gaapConfig._getGaapResultName(scalingFactor, "Optimal", algName)
377 try:
378 afwGeom.Quadrupole(aperShape.getParameterVector()-[targetSigma**2, targetSigma**2, 0.0],
379 normalize=True)
380 self.assertFalse(record[baseName + "_flag_bigPsf"])
381 except InvalidParameterError:
382 self.assertTrue(record[baseName + "_flag_bigPsf"])
384 # Ensure that the edge flag is set for the source at the corner.
385 record = catalog[2]
386 algorithm.measure(record, exposure)
387 self.assertTrue(record[algName + "_flag_edge"])
388 self.assertFalse(record[algName + "_flag"])
390 def recordPsfShape(self, catalog) -> None:
391 """Record PSF shapes under the appropriate fields in ``catalog``.
393 This method must be called after the dataset is realized and a catalog
394 is returned by the `realize` method. It assumes that the schema is
395 non-minimal and has "psfShape_xx", "psfShape_yy" and "psfShape_xy"
396 fields setup
398 Parameters
399 ----------
400 catalog : `~lsst.afw.table.SourceCatalog`
401 A source catalog containing records of the simulated sources.
402 """
403 psfShapeKey = afwTable.QuadrupoleKey(catalog.schema["slot_PsfShape"])
404 for record in catalog:
405 record.set(psfShapeKey, self.dataset.psfShape)
407 @staticmethod
408 def invertQuadrupole(shape: afwGeom.Quadrupole) -> afwGeom.Quadrupole:
409 """Compute the Quadrupole object corresponding to the inverse matrix.
411 If M = [[Q.getIxx(), Q.getIxy()],
412 [Q.getIxy(), Q.getIyy()]]
414 for the input quadrupole Q, the returned quadrupole R corresponds to
416 M^{-1} = [[R.getIxx(), R.getIxy()],
417 [R.getIxy(), R.getIyy()]].
418 """
419 invShape = afwGeom.Quadrupole(shape.getIyy(), shape.getIxx(), -shape.getIxy())
420 invShape.scale(1./shape.getDeterminantRadius()**2)
421 return invShape
423 @lsst.utils.tests.methodParameters(gaussianizationMethod=("auto", "overlap-add", "direct", "fft"))
424 def testGalaxyPhotometry(self, gaussianizationMethod):
425 """Test GAaP fluxes for extended sources.
427 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse
428 its outputs as reference for ForcedGaapFluxPlugin. In both cases,
429 the measured flux is compared with the analytical expectation.
431 For a Gaussian source with intrinsic shape S and intrinsic aperture W,
432 the GAaP flux is defined as (Eq. A16 of Kuijken et al. 2015)
433 :math:`\\frac{F}{2\\pi\\det(S)}\\int\\mathrm{d}x\\exp(-x^T(S^{-1}+W^{-1})x/2)`
434 :math:`F\\frac{\\det(S^{-1})}{\\det(S^{-1}+W^{-1})}`
435 """
436 algName = "ext_gaap_GaapFlux"
437 dependencies = ("base_SdssShape",)
438 sfmConfig = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies)
439 forcedConfig = self.makeForcedMeasurementConfig(algName, dependencies=dependencies)
440 # Turn on optimal photometry explicitly
441 sfmConfig.plugins[algName].doOptimalPhotometry = True
442 forcedConfig.plugins[algName].doOptimalPhotometry = True
443 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
444 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
446 algMetadata = lsst.daf.base.PropertyList()
447 sfmTask = self.makeSingleFrameMeasurementTask(config=sfmConfig, algMetadata=algMetadata)
448 forcedTask = self.makeForcedMeasurementTask(config=forcedConfig, algMetadata=algMetadata,
449 refSchema=sfmTask.schema)
451 refExposure, refCatalog = self.dataset.realize(0.0, sfmTask.schema)
452 self.recordPsfShape(refCatalog)
453 sfmTask.run(refCatalog, refExposure)
455 # Check if the measured values match the expectations from
456 # analytical Gaussian integrals
457 recordId = 1 # Elliptical Gaussian galaxy
458 refRecord = refCatalog[recordId]
459 refWcs = self.dataset.exposure.getWcs()
460 schema = refRecord.schema
461 trueFlux = refRecord["truth_instFlux"]
462 intrinsicShapeVector = afwTable.QuadrupoleKey(schema["truth"]).get(refRecord).getParameterVector() \
463 - afwTable.QuadrupoleKey(schema["slot_PsfShape"]).get(refRecord).getParameterVector()
464 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector)
465 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
466 # Assert that the measured fluxes agree with analytical expectations.
467 for sigma in sfmTask.config.plugins[algName]._sigmas:
468 if sigma == "Optimal":
469 aperShape = afwTable.QuadrupoleKey(schema[f"{algName}_OptimalShape"]).get(refRecord)
470 else:
471 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
472 aperShape.transformInPlace(refWcs.linearizeSkyToPixel(refRecord.getCentroid(),
473 geom.arcseconds).getLinear())
475 invAperShape = self.invertQuadrupole(aperShape)
476 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
477 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
478 for scalingFactor in sfmTask.config.plugins[algName].scalingFactors:
479 baseName = sfmTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
480 sigma, algName)
481 instFlux = refRecord.get(f"{baseName}_instFlux")
482 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
484 measWcs = self.dataset.makePerturbedWcs(refWcs, randomSeed=15)
485 measDataset = self.dataset.transform(measWcs)
486 measExposure, truthCatalog = measDataset.realize(0.0, schema)
487 measCatalog = forcedTask.generateMeasCat(measExposure, refCatalog, refWcs)
488 forcedTask.attachTransformedFootprints(measCatalog, refCatalog, measExposure, refWcs)
489 forcedTask.run(measCatalog, measExposure, refCatalog, refWcs)
491 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs)
492 localTransform = afwGeom.linearizeTransform(fullTransform, refRecord.getCentroid()).getLinear()
493 intrinsicShape.transformInPlace(localTransform)
494 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
495 measRecord = measCatalog[recordId]
497 # Since measCatalog and refCatalog differ only by WCS, the GAaP flux
498 # measured through consistent apertures must agree with each other.
499 for sigma in forcedTask.config.plugins[algName]._sigmas:
500 if sigma == "Optimal":
501 aperShape = afwTable.QuadrupoleKey(measRecord.schema[f"{algName}_"
502 "OptimalShape"]).get(measRecord)
503 else:
504 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
505 aperShape.transformInPlace(measWcs.linearizeSkyToPixel(measRecord.getCentroid(),
506 geom.arcseconds).getLinear())
508 invAperShape = self.invertQuadrupole(aperShape)
509 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
510 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
511 for scalingFactor in forcedTask.config.plugins[algName].scalingFactors:
512 baseName = forcedTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
513 sigma, algName)
514 instFlux = measRecord.get(f"{baseName}_instFlux")
515 # The measurement in the measRecord must be consistent with
516 # the same in the refRecord in addition to analyticalFlux.
517 self.assertFloatsAlmostEqual(instFlux, refRecord.get(f"{baseName}_instFlux"), rtol=5e-3)
518 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
520 def getFluxErrScaling(self, kernel, aperShape):
521 """Returns the value by which the standard error has to be scaled due
522 to noise correlations.
524 This is an alternative implementation to the `_getFluxErrScaling`
525 method of `BaseGaapFluxPlugin`, but is less efficient.
527 Parameters
528 ----------
529 `kernel` : `~lsst.afw.math.Kernel`
530 The PSF-Gaussianization kernel.
532 Returns
533 -------
534 fluxErrScaling : `float`
535 The factor by which the standard error on GAaP flux must be scaled.
536 """
537 kim = afwImage.ImageD(kernel.getDimensions())
538 kernel.computeImage(kim, False)
539 weight = galsim.Image(np.zeros_like(kim.array))
540 aperSigma = aperShape.getDeterminantRadius()
541 trace = aperShape.getIxx() + aperShape.getIyy()
542 distortion = galsim.Shear(e1=(aperShape.getIxx()-aperShape.getIyy())/trace,
543 e2=2*aperShape.getIxy()/trace)
544 gauss = galsim.Gaussian(sigma=aperSigma, flux=2*np.pi*aperSigma**2).shear(distortion)
545 weight = gauss.drawImage(image=weight, scale=1.0, method='no_pixel')
546 kwarr = scipy.signal.convolve2d(weight.array, kim.array, boundary='fill')
547 fluxErrScaling = np.sqrt(np.sum(kwarr*kwarr))
548 fluxErrScaling /= np.sqrt(np.pi*aperSigma**2)
549 return fluxErrScaling
551 def testCorrelatedNoiseError(self, sigmas=[0.6, 0.8], scalingFactors=[1.15, 1.2, 1.25, 1.3, 1.4]):
552 """Test the scaling to standard error due to correlated noise.
554 The uncertainty estimate on GAaP fluxes is scaled by an amount
555 determined by the auto-correlation function of the PSF-matching kernel;
556 see Eqs. A11 & A17 of Kuijken et al. (2015). This test ensures that the
557 calculation of the scaling factors matches the analytical expression
558 when the PSF-matching kernel is a Gaussian.
560 Parameters
561 ----------
562 sigmas : `list` [`float`], optional
563 A list of effective Gaussian aperture sizes.
564 scalingFactors : `list` [`float`], optional
565 A list of factors by which the PSF size must be scaled.
567 Notes
568 -----
569 This unit test tests internal states of the plugin for accuracy and is
570 specific to the implementation. It uses private variables as a result
571 and intentionally breaks encapsulation.
572 """
573 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
574 scalingFactors=scalingFactors)
575 gaapConfig.scaleByFwhm = True
577 algorithm, schema = self.makeAlgorithm(gaapConfig)
578 exposure, catalog = self.dataset.realize(0.0, schema)
579 wcs = exposure.getWcs()
580 record = catalog[0]
581 center = self.center
582 seeing = exposure.getPsf().computeShape(center).getDeterminantRadius()
583 for scalingFactor in gaapConfig.scalingFactors:
584 targetSigma = scalingFactor*seeing
585 modelPsf = afwDetection.GaussianPsf(algorithm.config._modelPsfDimension,
586 algorithm.config._modelPsfDimension,
587 targetSigma)
588 result = algorithm._gaussianize(exposure, modelPsf, record)
589 kernel = result.psfMatchingKernel
590 kernelAcf = algorithm._computeKernelAcf(kernel)
591 for sigma in gaapConfig.sigmas:
592 intrinsicShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
593 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, geom.arcseconds).getLinear())
594 aperShape = afwGeom.Quadrupole(intrinsicShape.getParameterVector()
595 - [targetSigma**2, targetSigma**2, 0.0])
596 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
597 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
599 # The PSF matching kernel is a Gaussian of sigma^2 = (f^2-1)s^2
600 # where f is the scalingFactor and s is the original seeing.
601 # The integral of ACF of the kernel times the elliptical
602 # Gaussian described by aperShape is given below.
603 sigma /= wcs.getPixelScale().asArcseconds()
604 analyticalValue = ((sigma**2 - (targetSigma)**2)/(sigma**2-seeing**2))**0.5
605 self.assertFloatsAlmostEqual(fluxErrScaling1, analyticalValue, rtol=1e-4)
606 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
608 # Try with an elliptical aperture. This is a proxy for
609 # optimal aperture, since we do not actually measure anything.
610 aperShape = afwGeom.Quadrupole(8, 6, 3)
611 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
612 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
613 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
615 @lsst.utils.tests.methodParameters(noise=(0.001, 0.01, 0.1))
616 def testMonteCarlo(self, noise, recordId=1, sigmas=[0.7, 1.0, 1.25],
617 scalingFactors=[1.1, 1.15, 1.2, 1.3, 1.4]):
618 """Test GAaP flux uncertainties.
620 This test should demonstate that the estimated flux uncertainties agree
621 with those from Monte Carlo simulations.
623 Parameters
624 ----------
625 noise : `float`
626 The RMS value of the Gaussian noise field divided by the total flux
627 of the source.
628 recordId : `int`, optional
629 The source Id in the test dataset to measure.
630 sigmas : `list` [`float`], optional
631 The list of sigmas (in pixels) to construct the `GaapFluxConfig`.
632 scalingFactors : `list` [`float`], optional
633 The list of scaling factors to construct the `GaapFluxConfig`.
634 """
635 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
636 scalingFactors=scalingFactors)
637 gaapConfig.scaleByFwhm = True
638 gaapConfig.doPsfPhotometry = True
639 gaapConfig.doOptimalPhotometry = True
641 algorithm, schema = self.makeAlgorithm(gaapConfig)
642 # Make a noiseless exposure and keep measurement record for reference
643 exposure, catalog = self.dataset.realize(0.0, schema)
644 if gaapConfig.doOptimalPhotometry:
645 self.recordPsfShape(catalog)
646 recordNoiseless = catalog[recordId]
647 totalFlux = recordNoiseless["truth_instFlux"]
648 algorithm.measure(recordNoiseless, exposure)
650 nSamples = 1024
651 catalog = afwTable.SourceCatalog(schema)
652 for repeat in range(nSamples):
653 exposure, cat = self.dataset.realize(noise*totalFlux, schema, randomSeed=repeat)
654 if gaapConfig.doOptimalPhotometry:
655 self.recordPsfShape(cat)
656 record = cat[recordId]
657 algorithm.measure(record, exposure)
658 catalog.append(record)
660 catalog = catalog.copy(deep=True)
661 for baseName in gaapConfig.getAllGaapResultNames():
662 instFluxKey = schema.join(baseName, "instFlux")
663 instFluxErrKey = schema.join(baseName, "instFluxErr")
664 instFluxMean = catalog[instFluxKey].mean()
665 instFluxErrMean = catalog[instFluxErrKey].mean()
666 instFluxStdDev = catalog[instFluxKey].std()
668 # GAaP fluxes are not meant to be total fluxes.
669 # We compare the mean of the noisy measurements to its
670 # corresponding noiseless measurement instead of the true value
671 instFlux = recordNoiseless[instFluxKey]
672 self.assertFloatsAlmostEqual(instFluxErrMean, instFluxStdDev, rtol=0.02)
673 self.assertLess(abs(instFluxMean - instFlux), 2.0*instFluxErrMean/nSamples**0.5)
676class TestMemory(lsst.utils.tests.MemoryTestCase):
677 pass
680def setup_module(module, backend="virtualDevice"):
681 lsst.utils.tests.init()
682 try:
683 afwDisplay.setDefaultBackend(backend)
684 except Exception:
685 print("Unable to configure display backend: %s" % backend)
688if __name__ == "__main__": 688 ↛ 689line 688 didn't jump to line 689, because the condition on line 688 was never true
689 import sys
691 from argparse import ArgumentParser
692 parser = ArgumentParser()
693 parser.add_argument('--backend', type=str, default="virtualDevice",
694 help="The backend to use, e.g. 'ds9'. Be sure to 'setup display_<backend>'")
695 args = parser.parse_args()
697 setup_module(sys.modules[__name__], backend=args.backend)
698 unittest.main()