Coverage for tests/test_psf.py: 14%
219 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-30 10:00 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-30 10:00 +0000
1import galsim # noqa: F401
2import unittest
3import numpy as np
4import copy
5from galsim import Lanczos # noqa: F401
7import lsst.utils.tests
8import lsst.afw.detection as afwDetection
9import lsst.afw.geom as afwGeom
10import lsst.afw.image as afwImage
11import lsst.afw.math as afwMath
12import lsst.afw.table as afwTable
13import lsst.daf.base as dafBase
14import lsst.geom as geom
15import lsst.meas.algorithms as measAlg
16from lsst.meas.base import SingleFrameMeasurementTask
17from lsst.meas.extensions.piff.piffPsfDeterminer import PiffPsfDeterminerConfig, PiffPsfDeterminerTask
18from lsst.meas.extensions.piff.piffPsfDeterminer import _validateGalsimInterpolant
19from lsst.pex.config import FieldValidationError
22def psfVal(ix, iy, x, y, sigma1, sigma2, b):
23 """Return the value at (ix, iy) of a double Gaussian
24 (N(0, sigma1^2) + b*N(0, sigma2^2))/(1 + b)
25 centered at (x, y)
26 """
27 dx, dy = x - ix, y - iy
28 theta = np.radians(30)
29 ab = 1.0/0.75 # axis ratio
30 c, s = np.cos(theta), np.sin(theta)
31 u, v = c*dx - s*dy, s*dx + c*dy
33 return (np.exp(-0.5*(u**2 + (v*ab)**2)/sigma1**2)
34 + b*np.exp(-0.5*(u**2 + (v*ab)**2)/sigma2**2))/(1 + b)
37class SpatialModelPsfTestCase(lsst.utils.tests.TestCase):
38 """A test case for SpatialModelPsf"""
40 def measure(self, footprintSet, exposure):
41 """Measure a set of Footprints, returning a SourceCatalog"""
42 catalog = afwTable.SourceCatalog(self.schema)
44 footprintSet.makeSources(catalog)
46 self.measureSources.run(catalog, exposure)
47 return catalog
49 def setUp(self):
50 config = SingleFrameMeasurementTask.ConfigClass()
51 config.slots.apFlux = 'base_CircularApertureFlux_12_0'
52 self.schema = afwTable.SourceTable.makeMinimalSchema()
54 self.measureSources = SingleFrameMeasurementTask(
55 self.schema, config=config
56 )
57 self.usePsfFlag = self.schema.addField("use_psf", type="Flag")
59 width, height = 110, 301
61 self.mi = afwImage.MaskedImageF(geom.ExtentI(width, height))
62 self.mi.set(0)
63 sd = 3 # standard deviation of image
64 self.mi.getVariance().set(sd*sd)
65 self.mi.getMask().addMaskPlane("DETECTED")
67 self.ksize = 31 # size of desired kernel
69 sigma1 = 1.75
70 sigma2 = 2*sigma1
72 self.exposure = afwImage.makeExposure(self.mi)
73 self.exposure.setPsf(measAlg.DoubleGaussianPsf(self.ksize, self.ksize,
74 1.5*sigma1, 1, 0.1))
75 cdMatrix = np.array([1.0, 0.0, 0.0, 1.0]) * 0.2/3600
76 cdMatrix.shape = (2, 2)
77 wcs = afwGeom.makeSkyWcs(crpix=geom.PointD(0, 0),
78 crval=geom.SpherePoint(0.0, 0.0, geom.degrees),
79 cdMatrix=cdMatrix)
80 self.exposure.setWcs(wcs)
82 #
83 # Make a kernel with the exactly correct basis functions.
84 # Useful for debugging
85 #
86 basisKernelList = []
87 for sigma in (sigma1, sigma2):
88 basisKernel = afwMath.AnalyticKernel(
89 self.ksize, self.ksize, afwMath.GaussianFunction2D(sigma, sigma)
90 )
91 basisImage = afwImage.ImageD(basisKernel.getDimensions())
92 basisKernel.computeImage(basisImage, True)
93 basisImage /= np.sum(basisImage.getArray())
95 if sigma == sigma1:
96 basisImage0 = basisImage
97 else:
98 basisImage -= basisImage0
100 basisKernelList.append(afwMath.FixedKernel(basisImage))
102 order = 1 # 1 => up to linear
103 spFunc = afwMath.PolynomialFunction2D(order)
105 exactKernel = afwMath.LinearCombinationKernel(basisKernelList, spFunc)
106 exactKernel.setSpatialParameters(
107 [[1.0, 0, 0],
108 [0.0, 0.5*1e-2, 0.2e-2]]
109 )
111 rand = afwMath.Random() # make these tests repeatable by setting seed
113 im = self.mi.getImage()
114 afwMath.randomGaussianImage(im, rand) # N(0, 1)
115 im *= sd # N(0, sd^2)
117 xarr, yarr = [], []
119 for x, y in [(20, 20), (60, 20),
120 (30, 35),
121 (50, 50),
122 (20, 90), (70, 160), (25, 265), (75, 275), (85, 30),
123 (50, 120), (70, 80),
124 (60, 210), (20, 210),
125 ]:
126 xarr.append(x)
127 yarr.append(y)
129 for x, y in zip(xarr, yarr):
130 dx = rand.uniform() - 0.5 # random (centered) offsets
131 dy = rand.uniform() - 0.5
133 k = exactKernel.getSpatialFunction(1)(x, y)
134 b = (k*sigma1**2/((1 - k)*sigma2**2))
136 flux = 80000*(1 + 0.1*(rand.uniform() - 0.5))
137 I0 = flux*(1 + b)/(2*np.pi*(sigma1**2 + b*sigma2**2))
138 for iy in range(y - self.ksize//2, y + self.ksize//2 + 1):
139 if iy < 0 or iy >= self.mi.getHeight():
140 continue
142 for ix in range(x - self.ksize//2, x + self.ksize//2 + 1):
143 if ix < 0 or ix >= self.mi.getWidth():
144 continue
146 II = I0*psfVal(ix, iy, x + dx, y + dy, sigma1, sigma2, b)
147 Isample = rand.poisson(II)
148 self.mi.image[ix, iy, afwImage.LOCAL] += Isample
149 self.mi.variance[ix, iy, afwImage.LOCAL] += II
151 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height))
152 self.cellSet = afwMath.SpatialCellSet(bbox, 100)
154 self.footprintSet = afwDetection.FootprintSet(
155 self.mi, afwDetection.Threshold(100), "DETECTED"
156 )
158 self.catalog = self.measure(self.footprintSet, self.exposure)
160 for source in self.catalog:
161 cand = measAlg.makePsfCandidate(source, self.exposure)
162 self.cellSet.insertCandidate(cand)
164 def setupDeterminer(
165 self,
166 stampSize=None,
167 debugStarData=False,
168 useCoordinates='pixel'
169 ):
170 """Setup the starSelector and psfDeterminer
172 Parameters
173 ----------
174 stampSize : `int`, optional
175 Set ``config.stampSize`` to this, if not None.
176 """
177 starSelectorClass = measAlg.sourceSelectorRegistry["objectSize"]
178 starSelectorConfig = starSelectorClass.ConfigClass()
179 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
180 starSelectorConfig.badFlags = [
181 "base_PixelFlags_flag_edge",
182 "base_PixelFlags_flag_interpolatedCenter",
183 "base_PixelFlags_flag_saturatedCenter",
184 "base_PixelFlags_flag_crCenter",
185 ]
186 # Set to match when the tolerance of the test was set
187 starSelectorConfig.widthStdAllowed = 0.5
189 self.starSelector = starSelectorClass(config=starSelectorConfig)
191 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
192 if stampSize is not None:
193 makePsfCandidatesConfig.kernelSize = stampSize
194 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
196 psfDeterminerConfig = PiffPsfDeterminerConfig()
197 psfDeterminerConfig.spatialOrder = 1
198 if stampSize is not None:
199 psfDeterminerConfig.stampSize = stampSize
200 psfDeterminerConfig.debugStarData = debugStarData
201 psfDeterminerConfig.useCoordinates = useCoordinates
203 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
205 def subtractStars(self, exposure, catalog, chi_lim=-1):
206 """Subtract the exposure's PSF from all the sources in catalog"""
207 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
209 subtracted = mi.Factory(mi, True)
210 for s in catalog:
211 xc, yc = s.getX(), s.getY()
212 bbox = subtracted.getBBox(afwImage.PARENT)
213 if bbox.contains(geom.PointI(int(xc), int(yc))):
214 measAlg.subtractPsf(psf, subtracted, xc, yc)
215 chi = subtracted.Factory(subtracted, True)
216 var = subtracted.getVariance()
217 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
218 chi /= var
220 chi_min = np.min(chi.getImage().getArray())
221 chi_max = np.max(chi.getImage().getArray())
222 print(chi_min, chi_max)
224 if chi_lim > 0:
225 self.assertGreater(chi_min, -chi_lim)
226 self.assertLess(chi_max, chi_lim)
228 def checkPiffDeterminer(self, **kwargs):
229 """Configure PiffPsfDeterminerTask and run basic tests on it.
231 Parameters
232 ----------
233 kwargs : `dict`, optional
234 Additional keyword arguments to pass to setupDeterminer.
235 """
236 self.setupDeterminer(**kwargs)
237 metadata = dafBase.PropertyList()
239 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
240 psfCandidateList = self.makePsfCandidates.run(
241 stars.sourceCat,
242 exposure=self.exposure
243 ).psfCandidates
244 psf, cellSet = self.psfDeterminer.determinePsf(
245 self.exposure,
246 psfCandidateList,
247 metadata,
248 flagKey=self.usePsfFlag
249 )
250 self.exposure.setPsf(psf)
252 self.assertEqual(len(psfCandidateList), metadata['numAvailStars'])
253 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
254 self.assertEqual(
255 psf.getAveragePosition(),
256 geom.Point2D(
257 np.mean([s.x for s in psf._piffResult.stars]),
258 np.mean([s.y for s in psf._piffResult.stars])
259 )
260 )
261 if self.psfDeterminer.config.debugStarData:
262 self.assertIn('image', psf._piffResult.stars[0].data.__dict__)
263 else:
264 self.assertNotIn('image', psf._piffResult.stars[0].data.__dict__)
266 # Test how well we can subtract the PSF model
267 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1)
269 # Test bboxes
270 for point in [
271 psf.getAveragePosition(),
272 geom.Point2D(),
273 geom.Point2D(1, 1)
274 ]:
275 self.assertEqual(
276 psf.computeBBox(point),
277 psf.computeKernelImage(point).getBBox()
278 )
279 self.assertEqual(
280 psf.computeKernelBBox(point),
281 psf.computeKernelImage(point).getBBox()
282 )
283 self.assertEqual(
284 psf.computeImageBBox(point),
285 psf.computeImage(point).getBBox()
286 )
288 # Some roundtrips
289 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
290 self.exposure.writeFits(tmpFile)
291 fitsIm = afwImage.ExposureF(tmpFile)
292 copyIm = copy.deepcopy(self.exposure)
294 for newIm in [fitsIm, copyIm]:
295 # Piff doesn't enable __eq__ for its results, so we just check
296 # that some PSF images come out the same.
297 for point in [
298 geom.Point2D(0, 0),
299 geom.Point2D(10, 100),
300 geom.Point2D(-200, 30),
301 geom.Point2D(float("nan")) # "nullPoint"
302 ]:
303 self.assertImagesAlmostEqual(
304 psf.computeImage(point),
305 newIm.getPsf().computeImage(point)
306 )
307 # Also check average position
308 newPsf = newIm.getPsf()
309 self.assertImagesAlmostEqual(
310 psf.computeImage(psf.getAveragePosition()),
311 newPsf.computeImage(newPsf.getAveragePosition())
312 )
314 def testPiffDeterminer_default(self):
315 """Test piff with the default config."""
316 self.checkPiffDeterminer()
318 def testPiffDeterminer_kernelSize27(self):
319 """Test Piff with a psf kernelSize of 27."""
320 self.checkPiffDeterminer(stampSize=27)
322 def testPiffDeterminer_debugStarData(self):
323 """Test Piff with debugStarData=True."""
324 self.checkPiffDeterminer(debugStarData=True)
326 def testPiffDeterminer_skyCoords(self):
327 """Test Piff sky coords."""
328 self.checkPiffDeterminer(useCoordinates='sky')
330 @lsst.utils.tests.methodParameters(samplingSize=[1.0, 0.9, 1.1])
331 def test_validatePsfCandidates(self, samplingSize):
332 """Test that `_validatePsfCandidates` raises for too-small candidates.
334 This should be independent of the samplingSize parameter.
335 """
336 drawSizeDict = {1.0: 27,
337 0.9: 31,
338 1.1: 25,
339 }
341 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
342 makePsfCandidatesConfig.kernelSize = 23
343 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
344 psfCandidateList = self.makePsfCandidates.run(
345 self.catalog,
346 exposure=self.exposure
347 ).psfCandidates
349 psfDeterminerConfig = PiffPsfDeterminerConfig()
350 psfDeterminerConfig.stampSize = drawSizeDict[samplingSize]
351 psfDeterminerConfig.samplingSize = samplingSize
352 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
354 with self.assertRaisesRegex(RuntimeError,
355 "stampSize=27 "
356 "pixels per side; found 23x23"):
357 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 27)
359 # This should not raise.
360 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 21)
363class PiffConfigTestCase(lsst.utils.tests.TestCase):
364 """A test case to check for valid Piff config"""
365 def testValidateGalsimInterpolant(self):
366 # Check that random strings are not valid interpolants.
367 self.assertFalse(_validateGalsimInterpolant("foo"))
368 # Check that the Lanczos order is an integer
369 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
370 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
371 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
372 # Check for various valid Lanczos interpolants
373 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
374 self.assertTrue(_validateGalsimInterpolant(interp))
375 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
376 # Evaluating the string should succeed. This is how Piff does it.
377 self.assertTrue(eval(interp))
378 # Check that interpolation methods are case sensitive.
379 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
380 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
381 self.assertFalse(_validateGalsimInterpolant(interp))
382 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
383 self.assertTrue(eval(f"galsim.{interp}"))
385 def testKernelSize(self): # TODO: Remove this test in DM-36311.
386 config = PiffPsfDeterminerConfig()
388 # Setting both stampSize and kernelSize should raise an error.
389 config.stampSize = 27
390 with self.assertWarns(FutureWarning):
391 config.kernelSize = 25
392 self.assertRaises(FieldValidationError, config.validate)
394 # even if they agree with each other
395 config.stampSize = 31
396 with self.assertWarns(FutureWarning):
397 config.kernelSize = 31
398 self.assertRaises(FieldValidationError, config.validate)
400 # Setting stampSize and kernelSize should be valid, because if not
401 # set, stampSize is set to the size of PSF candidates internally.
402 # This is only a temporary behavior and should go away in DM-36311.
403 config.stampSize = None
404 with self.assertWarns(FutureWarning):
405 config.kernelSize = None
406 config.validate()
409class TestMemory(lsst.utils.tests.MemoryTestCase):
410 pass
413def setup_module(module):
414 lsst.utils.tests.init()
417if __name__ == "__main__": 417 ↛ 418line 417 didn't jump to line 418, because the condition on line 417 was never true
418 lsst.utils.tests.init()
419 unittest.main()