Coverage for tests/test_gaap.py: 13%
382 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 09:26 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 09: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 # Remove sky coordinate plugin because we don't have the columns
146 # in the tests.
147 if "base_SkyCoord" in measConfig.plugins.names:
148 measConfig.plugins.names.remove("base_SkyCoord")
150 measConfig.plugins.names.add(algName)
152 if forced:
153 measConfig.copyColumns = {"id": "objectId", "parent": "parentObjectId"}
155 algConfig = measConfig.plugins[algName]
156 algConfig.scalingFactors = scalingFactors
157 algConfig.scaleByFwhm = True
158 algConfig.doPsfPhotometry = True
159 # Do not turn on optimal photometry; not robust for a point-source.
160 algConfig.doOptimalPhotometry = False
162 if forced:
163 offset = geom.Extent2D(-12.3, 45.6)
164 refWcs = exposure.getWcs().copyAtShiftedPixelOrigin(offset)
165 refSchema = afwTable.SourceTable.makeMinimalSchema()
166 centroidKey = afwTable.Point2DKey.addFields(refSchema, "my_centroid", doc="centroid",
167 unit="pixel")
168 shapeKey = afwTable.QuadrupoleKey.addFields(refSchema, "my_shape", "shape")
169 refSchema.getAliasMap().set("slot_Centroid", "my_centroid")
170 refSchema.getAliasMap().set("slot_Shape", "my_shape")
171 refSchema.addField("my_centroid_flag", type="Flag", doc="centroid flag")
172 refSchema.addField("my_shape_flag", type="Flag", doc="shape flag")
173 refCat = afwTable.SourceCatalog(refSchema)
174 refSource = refCat.addNew()
175 refSource.set(centroidKey, center + offset)
176 refSource.set(shapeKey, afwGeom.Quadrupole(1.0, 1.0, 0.0))
178 refSource.setCoord(refWcs.pixelToSky(refSource.get(centroidKey)))
179 taskInitArgs = (refSchema,)
180 taskRunArgs = (refCat, refWcs)
181 else:
182 taskInitArgs = (afwTable.SourceTable.makeMinimalSchema(),)
183 taskRunArgs = ()
185 # Activate undeblended measurement with the same configuration
186 measConfig.undeblended.names.add(algName)
187 measConfig.undeblended[algName] = measConfig.plugins[algName]
189 # We are no longer going to change the configs.
190 # So validate and freeze as they would happen when run from a CLI
191 measConfig.validate()
192 measConfig.freeze()
194 algMetadata = dafBase.PropertyList()
195 task = TaskClass(*taskInitArgs, config=measConfig, algMetadata=algMetadata)
197 schema = task.schema
198 measCat = afwTable.SourceCatalog(schema)
199 source = measCat.addNew()
200 source.getTable().setMetadata(algMetadata)
201 ss = afwDetection.FootprintSet(exposure.getMaskedImage(), afwDetection.Threshold(10.0))
202 fp = ss.getFootprints()[0]
203 source.setFootprint(fp)
205 task.run(measCat, exposure, *taskRunArgs)
207 if display:
208 disp = afwDisplay.Display(frame)
209 disp.mtv(exposure)
210 disp.dot("x", *center, origin=afwImage.PARENT, title="psfSigma=%f" % (psfSigma,))
212 self.assertFalse(source.get(algName + "_flag")) # algorithm succeeded
214 # We first check if it produces a positive number (non-nan)
215 for baseName in algConfig.getAllGaapResultNames(algName):
216 self.assertTrue((source.get(baseName + "_instFlux") >= 0))
217 self.assertTrue((source.get(baseName + "_instFluxErr") >= 0))
219 # For scalingFactor > 1, check if the measured value is close to truth.
220 for baseName in algConfig.getAllGaapResultNames(algName):
221 if "_1_0x_" not in baseName:
222 rtol = 0.1 if "PsfFlux" not in baseName else 0.2
223 self.assertFloatsAlmostEqual(source.get(baseName + "_instFlux"), flux, rtol=rtol)
225 def runGaap(self, forced, psfSigma, scalingFactors=(1.0, 1.05, 1.1, 1.15, 1.2, 1.5, 2.0)):
226 self.check(psfSigma=psfSigma, forced=forced, scalingFactors=scalingFactors)
228 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,))
229 def testGaapPluginUnforced(self, psfSigma):
230 """Run GAaP as Single-frame measurement plugin.
231 """
232 self.runGaap(False, psfSigma)
234 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,))
235 def testGaapPluginForced(self, psfSigma):
236 """Run GAaP as forced measurement plugin.
237 """
238 self.runGaap(True, psfSigma)
240 def testFail(self, scalingFactors=[100.], sigmas=[500.]):
241 """Test that the fail method sets the flags correctly.
243 Set config parameters that are guaranteed to raise exceptions,
244 and check that they are handled properly by the `fail` method and that
245 expected log messages are generated.
246 For failure modes not handled by the `fail` method, we test them
247 in the ``testFlags`` method.
248 """
249 algName = "ext_gaap_GaapFlux"
250 dependencies = ("base_SdssShape",)
251 config = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies)
252 gaapConfig = config.plugins[algName]
253 gaapConfig.scalingFactors = scalingFactors
254 gaapConfig.sigmas = sigmas
255 gaapConfig.doPsfPhotometry = True
256 gaapConfig.doOptimalPhotometry = True
258 gaapConfig.scaleByFwhm = True
259 self.assertTrue(gaapConfig.scaleByFwhm) # Test the getter method.
261 algMetadata = lsst.daf.base.PropertyList()
262 sfmTask = self.makeSingleFrameMeasurementTask(algName, dependencies=dependencies, config=config,
263 algMetadata=algMetadata)
264 exposure, catalog = self.dataset.realize(0.0, sfmTask.schema)
265 self.recordPsfShape(catalog)
267 # Expected debug messages in the logs when running `sfmTask`.
268 errorMessage = [("Failed to solve for PSF matching kernel in GAaP for (100.000000, 670.000000): "
269 "Problematic scaling factors = 100.0 "
270 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"),
271 ("MeasurementError in ext_gaap_GaapFlux.measure on record 1: "
272 "Failed to solve for PSF matching kernel"),
273 ("Failed to solve for PSF matching kernel in GAaP for (100.000000, 870.000000): "
274 "Problematic scaling factors = 100.0 "
275 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"),
276 ("MeasurementError in ext_gaap_GaapFlux.measure on record 2: "
277 "Failed to solve for PSF matching kernel"),
278 ("Failed to solve for PSF matching kernel in GAaP for (-10.000000, -20.000000): "
279 "Problematic scaling factors = 100.0 "
280 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"),
281 ("MeasurementError in ext_gaap_GaapFlux.measure on record 3: "
282 "Failed to solve for PSF matching kernel")]
284 testCatalog = catalog.copy(deep=True)
285 plugin_logger_name = sfmTask.log.getChild(algName).name
286 self.assertEqual(plugin_logger_name, "lsst.measurement.ext_gaap_GaapFlux")
287 with self.assertLogs(plugin_logger_name, "DEBUG") as cm:
288 sfmTask.run(testCatalog, exposure)
289 self.assertEqual([record.message for record in cm.records], errorMessage)
291 self._checkAllFlags(
292 testCatalog,
293 algName,
294 scalingFactors,
295 sigmas,
296 gaapConfig,
297 specificFlag="flag_gaussianization",
298 )
300 # Trigger a "not (psfSigma > 0) error":
301 exposureJunkPsf = exposure.clone()
302 testCatalog = catalog.copy(deep=True)
303 junkPsf = afwDetection.GaussianPsf(1, 1, 0)
304 exposureJunkPsf.setPsf(junkPsf)
305 sfmTask.run(testCatalog, exposureJunkPsf)
307 self._checkAllFlags(
308 testCatalog,
309 algName,
310 scalingFactors,
311 sigmas,
312 gaapConfig,
313 specificFlag="flag_gaussianization",
314 )
316 # Trigger a NoPixelError.
317 testCatalog = catalog.copy(deep=True)
318 testCatalog[0].setFootprint(afwDetection.Footprint())
319 with self.assertLogs(plugin_logger_name, "DEBUG") as cm:
320 sfmTask.run(testCatalog, exposure)
322 self.assertEqual(
323 cm.records[0].message,
324 "MeasurementError in ext_gaap_GaapFlux.measure on record 1: No good pixels in footprint",
325 )
326 self.assertEqual(testCatalog[f"{algName}_flag_no_pixel"][0], True)
327 self.assertEqual(testCatalog[f"{algName}_flag"][0], True)
329 self._checkAllFlags(testCatalog[0: 1], algName, scalingFactors, sigmas, gaapConfig, allFailFlag=True)
331 # Try and "fail" with no PSF.
332 # Since fatal exceptions are not caught by the measurement framework,
333 # use a context manager and catch it here.
334 exposure.setPsf(None)
335 with self.assertRaises(lsst.meas.base.FatalAlgorithmError):
336 sfmTask.run(catalog, exposure)
338 def _checkAllFlags(
339 self,
340 catalog,
341 algName,
342 scalingFactors,
343 sigmas,
344 gaapConfig,
345 specificFlag=None,
346 allFailFlag=False
347 ):
348 for record in catalog:
349 self.assertEqual(record[algName + "_flag"], allFailFlag)
350 for scalingFactor in scalingFactors:
351 if specificFlag is not None:
352 flagName = gaapConfig._getGaapResultName(scalingFactor, specificFlag, algName)
353 self.assertTrue(record[flagName])
354 for sigma in sigmas + ["Optimal"]:
355 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
356 self.assertTrue(record[baseName + "_flag"])
357 self.assertFalse(record[baseName + "_flag_bigPsf"])
358 baseName = gaapConfig._getGaapResultName(scalingFactor, "PsfFlux", algName)
359 self.assertTrue(record[baseName + "_flag"])
361 def testFlags(self, sigmas=[0.4, 0.5, 0.7], scalingFactors=[1.15, 1.25, 1.4, 100.]):
362 """Test that GAaP flags are set properly.
364 Specifically, we test that
366 1. for invalid combinations of config parameters, only the
367 appropriate flags are set and not that the entire measurement itself is
368 flagged.
369 2. for sources close to the edge, the edge flags are set.
371 Parameters
372 ----------
373 sigmas : `list` [`float`], optional
374 The list of sigmas (in arcseconds) to construct the
375 `SingleFrameGaapFluxConfig`.
376 scalingFactors : `list` [`float`], optional
377 The list of scaling factors to construct the
378 `SingleFrameGaapFluxConfig`.
380 Raises
381 -----
382 InvalidParameterError
383 Raised if none of the config parameters will fail a measurement.
385 Notes
386 -----
387 Since the seeing in the test dataset is 2 pixels, at least one of the
388 ``sigmas`` should be smaller than at least twice of one of the
389 ``scalingFactors`` to avoid the InvalidParameterError exception being
390 raised.
391 """
392 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
393 scalingFactors=scalingFactors)
394 gaapConfig.scaleByFwhm = True
395 gaapConfig.doOptimalPhotometry = True
397 # Make an instance of GAaP algorithm from a config
398 algName = "ext_gaap_GaapFlux"
399 algorithm, schema = self.makeAlgorithm(gaapConfig)
400 # Make a noiseless exposure and measurements for reference
401 exposure, catalog = self.dataset.realize(0.0, schema)
402 # Record the PSF shapes if optimal photometry is performed.
403 if gaapConfig.doOptimalPhotometry:
404 self.recordPsfShape(catalog)
406 record = catalog[0]
407 algorithm.measure(record, exposure)
408 seeing = exposure.getPsf().getSigma()
409 pixelScale = exposure.getWcs().getPixelScale().asArcseconds()
410 # Measurement must fail (i.e., flag_bigPsf and flag must be set) if
411 # sigma < scalingFactor * seeing
412 # Ensure that there is at least one combination of parameters that fail
413 if not (min(gaapConfig.sigmas)/pixelScale < seeing*max(gaapConfig.scalingFactors)):
414 raise InvalidParameterError("The config parameters do not trigger a measurement failure. "
415 "Consider including lower values in ``sigmas`` and/or larger values "
416 "for ``scalingFactors``")
417 # Ensure that the measurement is not a complete failure
418 self.assertFalse(record[algName + "_flag"])
419 self.assertFalse(record[algName + "_flag_edge"])
420 # Ensure that flag_bigPsf is set if sigma < scalingFactor * seeing
421 for scalingFactor, sigma in itertools.product(gaapConfig.scalingFactors, gaapConfig.sigmas):
422 targetSigma = scalingFactor*seeing
423 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName)
424 # Give some leeway for the edge case and compare against a small
425 # negative number instead of zero.
426 if targetSigma*pixelScale - sigma >= -2e-7:
427 self.assertTrue(record[baseName+"_flag_bigPsf"],
428 msg=f"bigPsf flag not set for {scalingFactor=} and {sigma=}",
429 )
430 self.assertTrue(record[baseName+"_flag"],
431 msg=f"Flag not set for {scalingFactor=} and {sigma=}",
432 )
433 else:
434 self.assertFalse(record[baseName+"_flag_bigPsf"],
435 msg=f"bigPsf flag set for {scalingFactor=} and {sigma=}",
436 )
437 self.assertFalse(record[baseName+"_flag"],
438 msg=f"Flag set for {scalingFactor=} and {sigma=}",
439 )
441 # Ensure that flag_bigPsf is set if OptimalShape is not large enough.
442 if gaapConfig.doOptimalPhotometry:
443 aperShape = afwTable.QuadrupoleKey(schema[schema.join(algName, "OptimalShape")]).get(record)
444 for scalingFactor in gaapConfig.scalingFactors:
445 targetSigma = scalingFactor*seeing
446 baseName = gaapConfig._getGaapResultName(scalingFactor, "Optimal", algName)
447 try:
448 afwGeom.Quadrupole(aperShape.getParameterVector()-[targetSigma**2, targetSigma**2, 0.0],
449 normalize=True)
450 self.assertFalse(record[baseName + "_flag_bigPsf"])
451 except InvalidParameterError:
452 self.assertTrue(record[baseName + "_flag_bigPsf"])
454 # Set an empty footprint and check that no_pixels flag is set.
455 record = catalog[1]
456 record.setFootprint(afwDetection.Footprint())
457 with self.assertRaises(lsst.meas.extensions.gaap._gaap.NoPixelError):
458 algorithm.measure(record, exposure)
459 self.assertTrue(record[algName + "_flag"])
460 self.assertTrue(record[algName + "_flag_no_pixel"])
462 # Ensure that the edge flag is set for the source at the corner.
463 record = catalog[2]
464 algorithm.measure(record, exposure)
465 self.assertTrue(record[algName + "_flag_edge"])
466 self.assertFalse(record[algName + "_flag"])
468 def recordPsfShape(self, catalog) -> None:
469 """Record PSF shapes under the appropriate fields in ``catalog``.
471 This method must be called after the dataset is realized and a catalog
472 is returned by the `realize` method. It assumes that the schema is
473 non-minimal and has "psfShape_xx", "psfShape_yy" and "psfShape_xy"
474 fields setup
476 Parameters
477 ----------
478 catalog : `~lsst.afw.table.SourceCatalog`
479 A source catalog containing records of the simulated sources.
480 """
481 psfShapeKey = afwTable.QuadrupoleKey(catalog.schema["slot_PsfShape"])
482 for record in catalog:
483 record.set(psfShapeKey, self.dataset.psfShape)
485 @staticmethod
486 def invertQuadrupole(shape: afwGeom.Quadrupole) -> afwGeom.Quadrupole:
487 """Compute the Quadrupole object corresponding to the inverse matrix.
489 If M = [[Q.getIxx(), Q.getIxy()],
490 [Q.getIxy(), Q.getIyy()]]
492 for the input quadrupole Q, the returned quadrupole R corresponds to
494 M^{-1} = [[R.getIxx(), R.getIxy()],
495 [R.getIxy(), R.getIyy()]].
496 """
497 invShape = afwGeom.Quadrupole(shape.getIyy(), shape.getIxx(), -shape.getIxy())
498 invShape.scale(1./shape.getDeterminantRadius()**2)
499 return invShape
501 @lsst.utils.tests.methodParameters(gaussianizationMethod=("auto", "overlap-add", "direct", "fft"))
502 def testGalaxyPhotometry(self, gaussianizationMethod):
503 """Test GAaP fluxes for extended sources.
505 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse
506 its outputs as reference for ForcedGaapFluxPlugin. In both cases,
507 the measured flux is compared with the analytical expectation.
509 For a Gaussian source with intrinsic shape S and intrinsic aperture W,
510 the GAaP flux is defined as (Eq. A16 of Kuijken et al. 2015)
511 :math:`\\frac{F}{2\\pi\\det(S)}\\int\\mathrm{d}x\\exp(-x^T(S^{-1}+W^{-1})x/2)`
512 :math:`F\\frac{\\det(S^{-1})}{\\det(S^{-1}+W^{-1})}`
513 """
514 algName = "ext_gaap_GaapFlux"
515 dependencies = ("base_SdssShape",)
516 sfmConfig = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies)
517 forcedConfig = self.makeForcedMeasurementConfig(algName, dependencies=dependencies)
518 # Turn on optimal photometry explicitly
519 sfmConfig.plugins[algName].doOptimalPhotometry = True
520 forcedConfig.plugins[algName].doOptimalPhotometry = True
521 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
522 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod
524 algMetadata = lsst.daf.base.PropertyList()
525 sfmTask = self.makeSingleFrameMeasurementTask(config=sfmConfig, algMetadata=algMetadata)
526 forcedTask = self.makeForcedMeasurementTask(config=forcedConfig, algMetadata=algMetadata,
527 refSchema=sfmTask.schema)
529 refExposure, refCatalog = self.dataset.realize(0.0, sfmTask.schema)
530 self.recordPsfShape(refCatalog)
531 sfmTask.run(refCatalog, refExposure)
533 # Check if the measured values match the expectations from
534 # analytical Gaussian integrals
535 recordId = 1 # Elliptical Gaussian galaxy
536 refRecord = refCatalog[recordId]
537 refWcs = self.dataset.exposure.getWcs()
538 schema = refRecord.schema
539 trueFlux = refRecord["truth_instFlux"]
540 intrinsicShapeVector = afwTable.QuadrupoleKey(schema["truth"]).get(refRecord).getParameterVector() \
541 - afwTable.QuadrupoleKey(schema["slot_PsfShape"]).get(refRecord).getParameterVector()
542 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector)
543 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
544 # Assert that the measured fluxes agree with analytical expectations.
545 for sigma in sfmTask.config.plugins[algName]._sigmas:
546 if sigma == "Optimal":
547 aperShape = afwTable.QuadrupoleKey(schema[f"{algName}_OptimalShape"]).get(refRecord)
548 else:
549 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
550 aperShape.transformInPlace(refWcs.linearizeSkyToPixel(refRecord.getCentroid(),
551 geom.arcseconds).getLinear())
553 invAperShape = self.invertQuadrupole(aperShape)
554 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
555 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
556 for scalingFactor in sfmTask.config.plugins[algName].scalingFactors:
557 baseName = sfmTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
558 sigma, algName)
559 instFlux = refRecord.get(f"{baseName}_instFlux")
560 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
562 measWcs = self.dataset.makePerturbedWcs(refWcs, randomSeed=15)
563 measDataset = self.dataset.transform(measWcs)
564 measExposure, truthCatalog = measDataset.realize(0.0, schema)
565 measCatalog = forcedTask.generateMeasCat(measExposure, refCatalog, refWcs)
566 forcedTask.attachTransformedFootprints(measCatalog, refCatalog, measExposure, refWcs)
567 forcedTask.run(measCatalog, measExposure, refCatalog, refWcs)
569 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs)
570 localTransform = afwGeom.linearizeTransform(fullTransform, refRecord.getCentroid()).getLinear()
571 intrinsicShape.transformInPlace(localTransform)
572 invIntrinsicShape = self.invertQuadrupole(intrinsicShape)
573 measRecord = measCatalog[recordId]
575 # Since measCatalog and refCatalog differ only by WCS, the GAaP flux
576 # measured through consistent apertures must agree with each other.
577 for sigma in forcedTask.config.plugins[algName]._sigmas:
578 if sigma == "Optimal":
579 aperShape = afwTable.QuadrupoleKey(measRecord.schema[f"{algName}_"
580 "OptimalShape"]).get(measRecord)
581 else:
582 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
583 aperShape.transformInPlace(measWcs.linearizeSkyToPixel(measRecord.getCentroid(),
584 geom.arcseconds).getLinear())
586 invAperShape = self.invertQuadrupole(aperShape)
587 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius()
588 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2
589 for scalingFactor in forcedTask.config.plugins[algName].scalingFactors:
590 baseName = forcedTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor,
591 sigma, algName)
592 instFlux = measRecord.get(f"{baseName}_instFlux")
593 # The measurement in the measRecord must be consistent with
594 # the same in the refRecord in addition to analyticalFlux.
595 self.assertFloatsAlmostEqual(instFlux, refRecord.get(f"{baseName}_instFlux"), rtol=5e-3)
596 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3)
598 def getFluxErrScaling(self, kernel, aperShape):
599 """Returns the value by which the standard error has to be scaled due
600 to noise correlations.
602 This is an alternative implementation to the `_getFluxErrScaling`
603 method of `BaseGaapFluxPlugin`, but is less efficient.
605 Parameters
606 ----------
607 `kernel` : `~lsst.afw.math.Kernel`
608 The PSF-Gaussianization kernel.
610 Returns
611 -------
612 fluxErrScaling : `float`
613 The factor by which the standard error on GAaP flux must be scaled.
614 """
615 kim = afwImage.ImageD(kernel.getDimensions())
616 kernel.computeImage(kim, False)
617 weight = galsim.Image(np.zeros_like(kim.array))
618 aperSigma = aperShape.getDeterminantRadius()
619 trace = aperShape.getIxx() + aperShape.getIyy()
620 distortion = galsim.Shear(e1=(aperShape.getIxx()-aperShape.getIyy())/trace,
621 e2=2*aperShape.getIxy()/trace)
622 gauss = galsim.Gaussian(sigma=aperSigma, flux=2*np.pi*aperSigma**2).shear(distortion)
623 weight = gauss.drawImage(image=weight, scale=1.0, method='no_pixel')
624 kwarr = scipy.signal.convolve2d(weight.array, kim.array, boundary='fill')
625 fluxErrScaling = np.sqrt(np.sum(kwarr*kwarr))
626 fluxErrScaling /= np.sqrt(np.pi*aperSigma**2)
627 return fluxErrScaling
629 def testCorrelatedNoiseError(self, sigmas=[0.6, 0.8], scalingFactors=[1.15, 1.2, 1.25, 1.3, 1.4]):
630 """Test the scaling to standard error due to correlated noise.
632 The uncertainty estimate on GAaP fluxes is scaled by an amount
633 determined by the auto-correlation function of the PSF-matching kernel;
634 see Eqs. A11 & A17 of Kuijken et al. (2015). This test ensures that the
635 calculation of the scaling factors matches the analytical expression
636 when the PSF-matching kernel is a Gaussian.
638 Parameters
639 ----------
640 sigmas : `list` [`float`], optional
641 A list of effective Gaussian aperture sizes.
642 scalingFactors : `list` [`float`], optional
643 A list of factors by which the PSF size must be scaled.
645 Notes
646 -----
647 This unit test tests internal states of the plugin for accuracy and is
648 specific to the implementation. It uses private variables as a result
649 and intentionally breaks encapsulation.
650 """
651 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
652 scalingFactors=scalingFactors)
653 gaapConfig.scaleByFwhm = True
655 algorithm, schema = self.makeAlgorithm(gaapConfig)
656 exposure, catalog = self.dataset.realize(0.0, schema)
657 wcs = exposure.getWcs()
658 record = catalog[0]
659 center = self.center
660 seeing = exposure.getPsf().computeShape(center).getDeterminantRadius()
661 for scalingFactor in gaapConfig.scalingFactors:
662 targetSigma = scalingFactor*seeing
663 modelPsf = afwDetection.GaussianPsf(algorithm.config._modelPsfDimension,
664 algorithm.config._modelPsfDimension,
665 targetSigma)
666 result = algorithm._gaussianize(exposure, modelPsf, record)
667 kernel = result.psfMatchingKernel
668 kernelAcf = algorithm._computeKernelAcf(kernel)
669 for sigma in gaapConfig.sigmas:
670 intrinsicShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0)
671 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, geom.arcseconds).getLinear())
672 aperShape = afwGeom.Quadrupole(intrinsicShape.getParameterVector()
673 - [targetSigma**2, targetSigma**2, 0.0])
674 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
675 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
677 # The PSF matching kernel is a Gaussian of sigma^2 = (f^2-1)s^2
678 # where f is the scalingFactor and s is the original seeing.
679 # The integral of ACF of the kernel times the elliptical
680 # Gaussian described by aperShape is given below.
681 sigma /= wcs.getPixelScale().asArcseconds()
682 analyticalValue = ((sigma**2 - (targetSigma)**2)/(sigma**2-seeing**2))**0.5
683 self.assertFloatsAlmostEqual(fluxErrScaling1, analyticalValue, rtol=1e-4)
684 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
686 # Try with an elliptical aperture. This is a proxy for
687 # optimal aperture, since we do not actually measure anything.
688 aperShape = afwGeom.Quadrupole(8, 6, 3)
689 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape)
690 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape)
691 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4)
693 @lsst.utils.tests.methodParameters(noise=(0.001, 0.01, 0.1))
694 def testMonteCarlo(self, noise, recordId=1, sigmas=[0.7, 1.0, 1.25],
695 scalingFactors=[1.1, 1.15, 1.2, 1.3, 1.4]):
696 """Test GAaP flux uncertainties.
698 This test should demonstate that the estimated flux uncertainties agree
699 with those from Monte Carlo simulations.
701 Parameters
702 ----------
703 noise : `float`
704 The RMS value of the Gaussian noise field divided by the total flux
705 of the source.
706 recordId : `int`, optional
707 The source Id in the test dataset to measure.
708 sigmas : `list` [`float`], optional
709 The list of sigmas (in pixels) to construct the `GaapFluxConfig`.
710 scalingFactors : `list` [`float`], optional
711 The list of scaling factors to construct the `GaapFluxConfig`.
712 """
713 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas,
714 scalingFactors=scalingFactors)
715 gaapConfig.scaleByFwhm = True
716 gaapConfig.doPsfPhotometry = True
717 gaapConfig.doOptimalPhotometry = True
719 algorithm, schema = self.makeAlgorithm(gaapConfig)
720 # Make a noiseless exposure and keep measurement record for reference
721 exposure, catalog = self.dataset.realize(0.0, schema)
722 if gaapConfig.doOptimalPhotometry:
723 self.recordPsfShape(catalog)
724 recordNoiseless = catalog[recordId]
725 totalFlux = recordNoiseless["truth_instFlux"]
726 algorithm.measure(recordNoiseless, exposure)
728 nSamples = 1024
729 catalog = afwTable.SourceCatalog(schema)
730 for repeat in range(nSamples):
731 exposure, cat = self.dataset.realize(noise*totalFlux, schema, randomSeed=repeat)
732 if gaapConfig.doOptimalPhotometry:
733 self.recordPsfShape(cat)
734 record = cat[recordId]
735 algorithm.measure(record, exposure)
736 catalog.append(record)
738 catalog = catalog.copy(deep=True)
739 for baseName in gaapConfig.getAllGaapResultNames():
740 instFluxKey = schema.join(baseName, "instFlux")
741 instFluxErrKey = schema.join(baseName, "instFluxErr")
742 instFluxMean = catalog[instFluxKey].mean()
743 instFluxErrMean = catalog[instFluxErrKey].mean()
744 instFluxStdDev = catalog[instFluxKey].std()
746 # GAaP fluxes are not meant to be total fluxes.
747 # We compare the mean of the noisy measurements to its
748 # corresponding noiseless measurement instead of the true value
749 instFlux = recordNoiseless[instFluxKey]
750 self.assertFloatsAlmostEqual(instFluxErrMean, instFluxStdDev, rtol=0.02)
751 self.assertLess(abs(instFluxMean - instFlux), 2.0*instFluxErrMean/nSamples**0.5)
754class TestMemory(lsst.utils.tests.MemoryTestCase):
755 pass
758def setup_module(module, backend="virtualDevice"):
759 lsst.utils.tests.init()
760 try:
761 afwDisplay.setDefaultBackend(backend)
762 except Exception:
763 print("Unable to configure display backend: %s" % backend)
766if __name__ == "__main__": 766 ↛ 767line 766 didn't jump to line 767, because the condition on line 766 was never true
767 import sys
769 from argparse import ArgumentParser
770 parser = ArgumentParser()
771 parser.add_argument('--backend', type=str, default="virtualDevice",
772 help="The backend to use, e.g. 'ds9'. Be sure to 'setup display_<backend>'")
773 args = parser.parse_args()
775 setup_module(sys.modules[__name__], backend=args.backend)
776 unittest.main()