Coverage for tests/test_gaap.py: 14%
356 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-05 11:26 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-05 11:26 +0000
1# This file is part of meas_extensions_gaap
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org/).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <http://www.lsstcorp.org/LegalNotices/>.
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 = [("Failed to solve for PSF matching kernel in GAaP for (100.000000, 670.000000): "
264 "Problematic scaling factors = 100.0 "
265 "Errors: Exception('Unable to determine kernel sum; 0 candidates')"),
266 ("Failed to solve for PSF matching kernel in GAaP for (100.000000, 870.000000): "
267 "Problematic scaling factors = 100.0 "
268 "Errors: Exception('Unable to determine kernel sum; 0 candidates')"),
269 ("Failed to solve for PSF matching kernel in GAaP for (-10.000000, -20.000000): "
270 "Problematic scaling factors = 100.0 "
271 "Errors: Exception('Unable to determine kernel sum; 0 candidates')")]
273 plugin_logger_name = sfmTask.log.getChild(algName).name
274 self.assertEqual(plugin_logger_name, "lsst.measurement.ext_gaap_GaapFlux")
275 with self.assertLogs(plugin_logger_name, "ERROR") as cm:
276 sfmTask.run(catalog, exposure)
277 self.assertEqual([record.message for record in cm.records], errorMessage)
279 for record in catalog:
280 self.assertFalse(record[algName + "_flag"])
281 for scalingFactor in scalingFactors:
282 flagName = gaapConfig._getGaapResultName(scalingFactor, "flag_gaussianization", algName)
283 self.assertTrue(record[flagName])
284 for sigma in sigmas + ["Optimal"]:
285 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
286 self.assertTrue(record[baseName + "_flag"])
287 self.assertFalse(record[baseName + "_flag_bigPsf"])
289 baseName = gaapConfig._getGaapResultName(scalingFactor, "PsfFlux", algName)
290 self.assertTrue(record[baseName + "_flag"])
292 # Try and "fail" with no PSF.
293 # Since fatal exceptions are not caught by the measurement framework,
294 # use a context manager and catch it here.
295 exposure.setPsf(None)
296 with self.assertRaises(lsst.meas.base.FatalAlgorithmError):
297 sfmTask.run(catalog, exposure)
299 def testFlags(self, sigmas=[0.4, 0.5, 0.7], scalingFactors=[1.15, 1.25, 1.4, 100.]):
300 """Test that GAaP flags are set properly.
302 Specifically, we test that
304 1. for invalid combinations of config parameters, only the
305 appropriate flags are set and not that the entire measurement itself is
306 flagged.
307 2. for sources close to the edge, the edge flags are set.
309 Parameters
310 ----------
311 sigmas : `list` [`float`], optional
312 The list of sigmas (in arcseconds) to construct the
313 `SingleFrameGaapFluxConfig`.
314 scalingFactors : `list` [`float`], optional
315 The list of scaling factors to construct the
316 `SingleFrameGaapFluxConfig`.
318 Raises
319 -----
320 InvalidParameterError
321 Raised if none of the config parameters will fail a measurement.
323 Notes
324 -----
325 Since the seeing in the test dataset is 2 pixels, at least one of the
326 ``sigmas`` should be smaller than at least twice of one of the
327 ``scalingFactors`` to avoid the InvalidParameterError exception being
328 raised.
329 """
330 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
331 scalingFactors=scalingFactors)
332 gaapConfig.scaleByFwhm = True
333 gaapConfig.doOptimalPhotometry = True
335 # Make an instance of GAaP algorithm from a config
336 algName = "ext_gaap_GaapFlux"
337 algorithm, schema = self.makeAlgorithm(gaapConfig)
338 # Make a noiseless exposure and measurements for reference
339 exposure, catalog = self.dataset.realize(0.0, schema)
340 # Record the PSF shapes if optimal photometry is performed.
341 if gaapConfig.doOptimalPhotometry:
342 self.recordPsfShape(catalog)
344 record = catalog[0]
345 algorithm.measure(record, exposure)
346 seeing = exposure.getPsf().getSigma()
347 pixelScale = exposure.getWcs().getPixelScale().asArcseconds()
348 # Measurement must fail (i.e., flag_bigPsf and flag must be set) if
349 # sigma < scalingFactor * seeing
350 # Ensure that there is at least one combination of parameters that fail
351 if not (min(gaapConfig.sigmas)/pixelScale < seeing*max(gaapConfig.scalingFactors)):
352 raise InvalidParameterError("The config parameters do not trigger a measurement failure. "
353 "Consider including lower values in ``sigmas`` and/or larger values "
354 "for ``scalingFactors``")
355 # Ensure that the measurement is not a complete failure
356 self.assertFalse(record[algName + "_flag"])
357 self.assertFalse(record[algName + "_flag_edge"])
358 # Ensure that flag_bigPsf is set if sigma < scalingFactor * seeing
359 for scalingFactor, sigma in itertools.product(gaapConfig.scalingFactors, gaapConfig.sigmas):
360 targetSigma = scalingFactor*seeing
361 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
362 # Give some leeway for the edge case and compare against a small
363 # negative number instead of zero.
364 if targetSigma*pixelScale - sigma >= -2e-7:
365 self.assertTrue(record[baseName+"_flag_bigPsf"],
366 msg=f"bigPsf flag not set for {scalingFactor=} and {sigma=}",
367 )
368 self.assertTrue(record[baseName+"_flag"],
369 msg=f"Flag not set for {scalingFactor=} and {sigma=}",
370 )
371 else:
372 self.assertFalse(record[baseName+"_flag_bigPsf"],
373 msg=f"bigPsf flag set for {scalingFactor=} and {sigma=}",
374 )
375 self.assertFalse(record[baseName+"_flag"],
376 msg=f"Flag set for {scalingFactor=} and {sigma=}",
377 )
379 # Ensure that flag_bigPsf is set if OptimalShape is not large enough.
380 if gaapConfig.doOptimalPhotometry:
381 aperShape = afwTable.QuadrupoleKey(schema[schema.join(algName, "OptimalShape")]).get(record)
382 for scalingFactor in gaapConfig.scalingFactors:
383 targetSigma = scalingFactor*seeing
384 baseName = gaapConfig._getGaapResultName(scalingFactor, "Optimal", algName)
385 try:
386 afwGeom.Quadrupole(aperShape.getParameterVector()-[targetSigma**2, targetSigma**2, 0.0],
387 normalize=True)
388 self.assertFalse(record[baseName + "_flag_bigPsf"])
389 except InvalidParameterError:
390 self.assertTrue(record[baseName + "_flag_bigPsf"])
392 # Ensure that the edge flag is set for the source at the corner.
393 record = catalog[2]
394 algorithm.measure(record, exposure)
395 self.assertTrue(record[algName + "_flag_edge"])
396 self.assertFalse(record[algName + "_flag"])
398 def recordPsfShape(self, catalog) -> None:
399 """Record PSF shapes under the appropriate fields in ``catalog``.
401 This method must be called after the dataset is realized and a catalog
402 is returned by the `realize` method. It assumes that the schema is
403 non-minimal and has "psfShape_xx", "psfShape_yy" and "psfShape_xy"
404 fields setup
406 Parameters
407 ----------
408 catalog : `~lsst.afw.table.SourceCatalog`
409 A source catalog containing records of the simulated sources.
410 """
411 psfShapeKey = afwTable.QuadrupoleKey(catalog.schema["slot_PsfShape"])
412 for record in catalog:
413 record.set(psfShapeKey, self.dataset.psfShape)
415 @staticmethod
416 def invertQuadrupole(shape: afwGeom.Quadrupole) -> afwGeom.Quadrupole:
417 """Compute the Quadrupole object corresponding to the inverse matrix.
419 If M = [[Q.getIxx(), Q.getIxy()],
420 [Q.getIxy(), Q.getIyy()]]
422 for the input quadrupole Q, the returned quadrupole R corresponds to
424 M^{-1} = [[R.getIxx(), R.getIxy()],
425 [R.getIxy(), R.getIyy()]].
426 """
427 invShape = afwGeom.Quadrupole(shape.getIyy(), shape.getIxx(), -shape.getIxy())
428 invShape.scale(1./shape.getDeterminantRadius()**2)
429 return invShape
431 @lsst.utils.tests.methodParameters(gaussianizationMethod=("auto", "overlap-add", "direct", "fft"))
432 def testGalaxyPhotometry(self, gaussianizationMethod):
433 """Test GAaP fluxes for extended sources.
435 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse
436 its outputs as reference for ForcedGaapFluxPlugin. In both cases,
437 the measured flux is compared with the analytical expectation.
439 For a Gaussian source with intrinsic shape S and intrinsic aperture W,
440 the GAaP flux is defined as (Eq. A16 of Kuijken et al. 2015)
441 :math:`\\frac{F}{2\\pi\\det(S)}\\int\\mathrm{d}x\\exp(-x^T(S^{-1}+W^{-1})x/2)`
442 :math:`F\\frac{\\det(S^{-1})}{\\det(S^{-1}+W^{-1})}`
443 """
444 algName = "ext_gaap_GaapFlux"
445 dependencies = ("base_SdssShape",)
446 sfmConfig = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies)
447 forcedConfig = self.makeForcedMeasurementConfig(algName, dependencies=dependencies)
448 # Turn on optimal photometry explicitly
449 sfmConfig.plugins[algName].doOptimalPhotometry = True
450 forcedConfig.plugins[algName].doOptimalPhotometry = True
451 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
452 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
454 algMetadata = lsst.daf.base.PropertyList()
455 sfmTask = self.makeSingleFrameMeasurementTask(config=sfmConfig, algMetadata=algMetadata)
456 forcedTask = self.makeForcedMeasurementTask(config=forcedConfig, algMetadata=algMetadata,
457 refSchema=sfmTask.schema)
459 refExposure, refCatalog = self.dataset.realize(0.0, sfmTask.schema)
460 self.recordPsfShape(refCatalog)
461 sfmTask.run(refCatalog, refExposure)
463 # Check if the measured values match the expectations from
464 # analytical Gaussian integrals
465 recordId = 1 # Elliptical Gaussian galaxy
466 refRecord = refCatalog[recordId]
467 refWcs = self.dataset.exposure.getWcs()
468 schema = refRecord.schema
469 trueFlux = refRecord["truth_instFlux"]
470 intrinsicShapeVector = afwTable.QuadrupoleKey(schema["truth"]).get(refRecord).getParameterVector() \
471 - afwTable.QuadrupoleKey(schema["slot_PsfShape"]).get(refRecord).getParameterVector()
472 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector)
473 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
474 # Assert that the measured fluxes agree with analytical expectations.
475 for sigma in sfmTask.config.plugins[algName]._sigmas:
476 if sigma == "Optimal":
477 aperShape = afwTable.QuadrupoleKey(schema[f"{algName}_OptimalShape"]).get(refRecord)
478 else:
479 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
480 aperShape.transformInPlace(refWcs.linearizeSkyToPixel(refRecord.getCentroid(),
481 geom.arcseconds).getLinear())
483 invAperShape = self.invertQuadrupole(aperShape)
484 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
485 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
486 for scalingFactor in sfmTask.config.plugins[algName].scalingFactors:
487 baseName = sfmTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
488 sigma, algName)
489 instFlux = refRecord.get(f"{baseName}_instFlux")
490 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
492 measWcs = self.dataset.makePerturbedWcs(refWcs, randomSeed=15)
493 measDataset = self.dataset.transform(measWcs)
494 measExposure, truthCatalog = measDataset.realize(0.0, schema)
495 measCatalog = forcedTask.generateMeasCat(measExposure, refCatalog, refWcs)
496 forcedTask.attachTransformedFootprints(measCatalog, refCatalog, measExposure, refWcs)
497 forcedTask.run(measCatalog, measExposure, refCatalog, refWcs)
499 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs)
500 localTransform = afwGeom.linearizeTransform(fullTransform, refRecord.getCentroid()).getLinear()
501 intrinsicShape.transformInPlace(localTransform)
502 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
503 measRecord = measCatalog[recordId]
505 # Since measCatalog and refCatalog differ only by WCS, the GAaP flux
506 # measured through consistent apertures must agree with each other.
507 for sigma in forcedTask.config.plugins[algName]._sigmas:
508 if sigma == "Optimal":
509 aperShape = afwTable.QuadrupoleKey(measRecord.schema[f"{algName}_"
510 "OptimalShape"]).get(measRecord)
511 else:
512 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
513 aperShape.transformInPlace(measWcs.linearizeSkyToPixel(measRecord.getCentroid(),
514 geom.arcseconds).getLinear())
516 invAperShape = self.invertQuadrupole(aperShape)
517 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
518 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
519 for scalingFactor in forcedTask.config.plugins[algName].scalingFactors:
520 baseName = forcedTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
521 sigma, algName)
522 instFlux = measRecord.get(f"{baseName}_instFlux")
523 # The measurement in the measRecord must be consistent with
524 # the same in the refRecord in addition to analyticalFlux.
525 self.assertFloatsAlmostEqual(instFlux, refRecord.get(f"{baseName}_instFlux"), rtol=5e-3)
526 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
528 def getFluxErrScaling(self, kernel, aperShape):
529 """Returns the value by which the standard error has to be scaled due
530 to noise correlations.
532 This is an alternative implementation to the `_getFluxErrScaling`
533 method of `BaseGaapFluxPlugin`, but is less efficient.
535 Parameters
536 ----------
537 `kernel` : `~lsst.afw.math.Kernel`
538 The PSF-Gaussianization kernel.
540 Returns
541 -------
542 fluxErrScaling : `float`
543 The factor by which the standard error on GAaP flux must be scaled.
544 """
545 kim = afwImage.ImageD(kernel.getDimensions())
546 kernel.computeImage(kim, False)
547 weight = galsim.Image(np.zeros_like(kim.array))
548 aperSigma = aperShape.getDeterminantRadius()
549 trace = aperShape.getIxx() + aperShape.getIyy()
550 distortion = galsim.Shear(e1=(aperShape.getIxx()-aperShape.getIyy())/trace,
551 e2=2*aperShape.getIxy()/trace)
552 gauss = galsim.Gaussian(sigma=aperSigma, flux=2*np.pi*aperSigma**2).shear(distortion)
553 weight = gauss.drawImage(image=weight, scale=1.0, method='no_pixel')
554 kwarr = scipy.signal.convolve2d(weight.array, kim.array, boundary='fill')
555 fluxErrScaling = np.sqrt(np.sum(kwarr*kwarr))
556 fluxErrScaling /= np.sqrt(np.pi*aperSigma**2)
557 return fluxErrScaling
559 def testCorrelatedNoiseError(self, sigmas=[0.6, 0.8], scalingFactors=[1.15, 1.2, 1.25, 1.3, 1.4]):
560 """Test the scaling to standard error due to correlated noise.
562 The uncertainty estimate on GAaP fluxes is scaled by an amount
563 determined by the auto-correlation function of the PSF-matching kernel;
564 see Eqs. A11 & A17 of Kuijken et al. (2015). This test ensures that the
565 calculation of the scaling factors matches the analytical expression
566 when the PSF-matching kernel is a Gaussian.
568 Parameters
569 ----------
570 sigmas : `list` [`float`], optional
571 A list of effective Gaussian aperture sizes.
572 scalingFactors : `list` [`float`], optional
573 A list of factors by which the PSF size must be scaled.
575 Notes
576 -----
577 This unit test tests internal states of the plugin for accuracy and is
578 specific to the implementation. It uses private variables as a result
579 and intentionally breaks encapsulation.
580 """
581 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
582 scalingFactors=scalingFactors)
583 gaapConfig.scaleByFwhm = True
585 algorithm, schema = self.makeAlgorithm(gaapConfig)
586 exposure, catalog = self.dataset.realize(0.0, schema)
587 wcs = exposure.getWcs()
588 record = catalog[0]
589 center = self.center
590 seeing = exposure.getPsf().computeShape(center).getDeterminantRadius()
591 for scalingFactor in gaapConfig.scalingFactors:
592 targetSigma = scalingFactor*seeing
593 modelPsf = afwDetection.GaussianPsf(algorithm.config._modelPsfDimension,
594 algorithm.config._modelPsfDimension,
595 targetSigma)
596 result = algorithm._gaussianize(exposure, modelPsf, record)
597 kernel = result.psfMatchingKernel
598 kernelAcf = algorithm._computeKernelAcf(kernel)
599 for sigma in gaapConfig.sigmas:
600 intrinsicShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
601 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, geom.arcseconds).getLinear())
602 aperShape = afwGeom.Quadrupole(intrinsicShape.getParameterVector()
603 - [targetSigma**2, targetSigma**2, 0.0])
604 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
605 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
607 # The PSF matching kernel is a Gaussian of sigma^2 = (f^2-1)s^2
608 # where f is the scalingFactor and s is the original seeing.
609 # The integral of ACF of the kernel times the elliptical
610 # Gaussian described by aperShape is given below.
611 sigma /= wcs.getPixelScale().asArcseconds()
612 analyticalValue = ((sigma**2 - (targetSigma)**2)/(sigma**2-seeing**2))**0.5
613 self.assertFloatsAlmostEqual(fluxErrScaling1, analyticalValue, rtol=1e-4)
614 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
616 # Try with an elliptical aperture. This is a proxy for
617 # optimal aperture, since we do not actually measure anything.
618 aperShape = afwGeom.Quadrupole(8, 6, 3)
619 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
620 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
621 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
623 @lsst.utils.tests.methodParameters(noise=(0.001, 0.01, 0.1))
624 def testMonteCarlo(self, noise, recordId=1, sigmas=[0.7, 1.0, 1.25],
625 scalingFactors=[1.1, 1.15, 1.2, 1.3, 1.4]):
626 """Test GAaP flux uncertainties.
628 This test should demonstate that the estimated flux uncertainties agree
629 with those from Monte Carlo simulations.
631 Parameters
632 ----------
633 noise : `float`
634 The RMS value of the Gaussian noise field divided by the total flux
635 of the source.
636 recordId : `int`, optional
637 The source Id in the test dataset to measure.
638 sigmas : `list` [`float`], optional
639 The list of sigmas (in pixels) to construct the `GaapFluxConfig`.
640 scalingFactors : `list` [`float`], optional
641 The list of scaling factors to construct the `GaapFluxConfig`.
642 """
643 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
644 scalingFactors=scalingFactors)
645 gaapConfig.scaleByFwhm = True
646 gaapConfig.doPsfPhotometry = True
647 gaapConfig.doOptimalPhotometry = True
649 algorithm, schema = self.makeAlgorithm(gaapConfig)
650 # Make a noiseless exposure and keep measurement record for reference
651 exposure, catalog = self.dataset.realize(0.0, schema)
652 if gaapConfig.doOptimalPhotometry:
653 self.recordPsfShape(catalog)
654 recordNoiseless = catalog[recordId]
655 totalFlux = recordNoiseless["truth_instFlux"]
656 algorithm.measure(recordNoiseless, exposure)
658 nSamples = 1024
659 catalog = afwTable.SourceCatalog(schema)
660 for repeat in range(nSamples):
661 exposure, cat = self.dataset.realize(noise*totalFlux, schema, randomSeed=repeat)
662 if gaapConfig.doOptimalPhotometry:
663 self.recordPsfShape(cat)
664 record = cat[recordId]
665 algorithm.measure(record, exposure)
666 catalog.append(record)
668 catalog = catalog.copy(deep=True)
669 for baseName in gaapConfig.getAllGaapResultNames():
670 instFluxKey = schema.join(baseName, "instFlux")
671 instFluxErrKey = schema.join(baseName, "instFluxErr")
672 instFluxMean = catalog[instFluxKey].mean()
673 instFluxErrMean = catalog[instFluxErrKey].mean()
674 instFluxStdDev = catalog[instFluxKey].std()
676 # GAaP fluxes are not meant to be total fluxes.
677 # We compare the mean of the noisy measurements to its
678 # corresponding noiseless measurement instead of the true value
679 instFlux = recordNoiseless[instFluxKey]
680 self.assertFloatsAlmostEqual(instFluxErrMean, instFluxStdDev, rtol=0.02)
681 self.assertLess(abs(instFluxMean - instFlux), 2.0*instFluxErrMean/nSamples**0.5)
684class TestMemory(lsst.utils.tests.MemoryTestCase):
685 pass
688def setup_module(module, backend="virtualDevice"):
689 lsst.utils.tests.init()
690 try:
691 afwDisplay.setDefaultBackend(backend)
692 except Exception:
693 print("Unable to configure display backend: %s" % backend)
696if __name__ == "__main__": 696 ↛ 697line 696 didn't jump to line 697, because the condition on line 696 was never true
697 import sys
699 from argparse import ArgumentParser
700 parser = ArgumentParser()
701 parser.add_argument('--backend', type=str, default="virtualDevice",
702 help="The backend to use, e.g. 'ds9'. Be sure to 'setup display_<backend>'")
703 args = parser.parse_args()
705 setup_module(sys.modules[__name__], backend=args.backend)
706 unittest.main()