Coverage for tests/test_psf.py: 14%
214 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 03:29 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 03:29 -0700
1# This file is part of meas_extensions_piff.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://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 GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import galsim # noqa: F401
23import unittest
24import numpy as np
25import copy
26from galsim import Lanczos # noqa: F401
27import logging
29import lsst.utils.tests
30import lsst.afw.detection as afwDetection
31import lsst.afw.geom as afwGeom
32import lsst.afw.image as afwImage
33import lsst.afw.math as afwMath
34import lsst.afw.table as afwTable
35import lsst.daf.base as dafBase
36import lsst.geom as geom
37import lsst.meas.algorithms as measAlg
38from lsst.meas.base import SingleFrameMeasurementTask
39from lsst.meas.extensions.piff.piffPsfDeterminer import PiffPsfDeterminerConfig, PiffPsfDeterminerTask
40from lsst.meas.extensions.piff.piffPsfDeterminer import _validateGalsimInterpolant
43def psfVal(ix, iy, x, y, sigma1, sigma2, b):
44 """Return the value at (ix, iy) of a double Gaussian
45 (N(0, sigma1^2) + b*N(0, sigma2^2))/(1 + b)
46 centered at (x, y)
47 """
48 dx, dy = x - ix, y - iy
49 theta = np.radians(30)
50 ab = 1.0/0.75 # axis ratio
51 c, s = np.cos(theta), np.sin(theta)
52 u, v = c*dx - s*dy, s*dx + c*dy
54 return (np.exp(-0.5*(u**2 + (v*ab)**2)/sigma1**2)
55 + b*np.exp(-0.5*(u**2 + (v*ab)**2)/sigma2**2))/(1 + b)
58class SpatialModelPsfTestCase(lsst.utils.tests.TestCase):
59 """A test case for SpatialModelPsf"""
61 def measure(self, footprintSet, exposure):
62 """Measure a set of Footprints, returning a SourceCatalog"""
63 catalog = afwTable.SourceCatalog(self.schema)
65 footprintSet.makeSources(catalog)
67 self.measureSources.run(catalog, exposure)
68 return catalog
70 def setUp(self):
71 config = SingleFrameMeasurementTask.ConfigClass()
72 config.plugins.names = [
73 "base_PsfFlux",
74 "base_GaussianFlux",
75 "base_SdssCentroid",
76 "base_SdssShape",
77 "base_PixelFlags",
78 "base_CircularApertureFlux",
79 ]
80 config.slots.apFlux = 'base_CircularApertureFlux_12_0'
81 self.schema = afwTable.SourceTable.makeMinimalSchema()
83 self.measureSources = SingleFrameMeasurementTask(
84 self.schema, config=config
85 )
86 self.usePsfFlag = self.schema.addField("use_psf", type="Flag")
88 width, height = 110, 301
90 self.mi = afwImage.MaskedImageF(geom.ExtentI(width, height))
91 self.mi.set(0)
92 sd = 3 # standard deviation of image
93 self.mi.getVariance().set(sd*sd)
94 self.mi.getMask().addMaskPlane("DETECTED")
96 self.ksize = 31 # size of desired kernel
98 sigma1 = 1.75
99 sigma2 = 2*sigma1
101 self.exposure = afwImage.makeExposure(self.mi)
102 self.exposure.setPsf(measAlg.DoubleGaussianPsf(self.ksize, self.ksize,
103 1.5*sigma1, 1, 0.1))
104 cdMatrix = np.array([1.0, 0.0, 0.0, 1.0]) * 0.2/3600
105 cdMatrix.shape = (2, 2)
106 wcs = afwGeom.makeSkyWcs(crpix=geom.PointD(0, 0),
107 crval=geom.SpherePoint(0.0, 0.0, geom.degrees),
108 cdMatrix=cdMatrix)
109 self.exposure.setWcs(wcs)
111 #
112 # Make a kernel with the exactly correct basis functions.
113 # Useful for debugging
114 #
115 basisKernelList = []
116 for sigma in (sigma1, sigma2):
117 basisKernel = afwMath.AnalyticKernel(
118 self.ksize, self.ksize, afwMath.GaussianFunction2D(sigma, sigma)
119 )
120 basisImage = afwImage.ImageD(basisKernel.getDimensions())
121 basisKernel.computeImage(basisImage, True)
122 basisImage /= np.sum(basisImage.getArray())
124 if sigma == sigma1:
125 basisImage0 = basisImage
126 else:
127 basisImage -= basisImage0
129 basisKernelList.append(afwMath.FixedKernel(basisImage))
131 order = 1 # 1 => up to linear
132 spFunc = afwMath.PolynomialFunction2D(order)
134 exactKernel = afwMath.LinearCombinationKernel(basisKernelList, spFunc)
135 exactKernel.setSpatialParameters(
136 [[1.0, 0, 0],
137 [0.0, 0.5*1e-2, 0.2e-2]]
138 )
140 rand = afwMath.Random() # make these tests repeatable by setting seed
142 im = self.mi.getImage()
143 afwMath.randomGaussianImage(im, rand) # N(0, 1)
144 im *= sd # N(0, sd^2)
146 xarr, yarr = [], []
148 for x, y in [(20, 20), (60, 20),
149 (30, 35),
150 (50, 50),
151 (20, 90), (70, 160), (25, 265), (75, 275), (85, 30),
152 (50, 120), (70, 80),
153 (60, 210), (20, 210),
154 ]:
155 xarr.append(x)
156 yarr.append(y)
158 for x, y in zip(xarr, yarr):
159 dx = rand.uniform() - 0.5 # random (centered) offsets
160 dy = rand.uniform() - 0.5
162 k = exactKernel.getSpatialFunction(1)(x, y)
163 b = (k*sigma1**2/((1 - k)*sigma2**2))
165 flux = 80000*(1 + 0.1*(rand.uniform() - 0.5))
166 I0 = flux*(1 + b)/(2*np.pi*(sigma1**2 + b*sigma2**2))
167 for iy in range(y - self.ksize//2, y + self.ksize//2 + 1):
168 if iy < 0 or iy >= self.mi.getHeight():
169 continue
171 for ix in range(x - self.ksize//2, x + self.ksize//2 + 1):
172 if ix < 0 or ix >= self.mi.getWidth():
173 continue
175 II = I0*psfVal(ix, iy, x + dx, y + dy, sigma1, sigma2, b)
176 Isample = rand.poisson(II)
177 self.mi.image[ix, iy, afwImage.LOCAL] += Isample
178 self.mi.variance[ix, iy, afwImage.LOCAL] += II
180 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height))
181 self.cellSet = afwMath.SpatialCellSet(bbox, 100)
183 self.footprintSet = afwDetection.FootprintSet(
184 self.mi, afwDetection.Threshold(100), "DETECTED"
185 )
187 self.catalog = self.measure(self.footprintSet, self.exposure)
189 for source in self.catalog:
190 cand = measAlg.makePsfCandidate(source, self.exposure)
191 self.cellSet.insertCandidate(cand)
193 def setupDeterminer(
194 self,
195 stampSize=None,
196 debugStarData=False,
197 useCoordinates='pixel',
198 downsample=False,
199 withlog=False,
200 ):
201 """Setup the starSelector and psfDeterminer
203 Parameters
204 ----------
205 stampSize : `int`, optional
206 Set ``config.stampSize`` to this, if not None.
207 """
208 starSelectorClass = measAlg.sourceSelectorRegistry["objectSize"]
209 starSelectorConfig = starSelectorClass.ConfigClass()
210 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
211 starSelectorConfig.badFlags = [
212 "base_PixelFlags_flag_edge",
213 "base_PixelFlags_flag_interpolatedCenter",
214 "base_PixelFlags_flag_saturatedCenter",
215 "base_PixelFlags_flag_crCenter",
216 ]
217 # Set to match when the tolerance of the test was set
218 starSelectorConfig.widthStdAllowed = 0.5
220 self.starSelector = starSelectorClass(config=starSelectorConfig)
222 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
223 if stampSize is not None:
224 makePsfCandidatesConfig.kernelSize = stampSize
225 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
227 psfDeterminerConfig = PiffPsfDeterminerConfig()
228 psfDeterminerConfig.spatialOrder = 1
229 if stampSize is not None:
230 psfDeterminerConfig.stampSize = stampSize
231 psfDeterminerConfig.debugStarData = debugStarData
232 psfDeterminerConfig.useCoordinates = useCoordinates
233 if downsample:
234 psfDeterminerConfig.maxCandidates = 10
235 if withlog:
236 psfDeterminerConfig.piffLoggingLevel = 1
238 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
240 def subtractStars(self, exposure, catalog, chi_lim=-1):
241 """Subtract the exposure's PSF from all the sources in catalog"""
242 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
244 subtracted = mi.Factory(mi, True)
245 for s in catalog:
246 xc, yc = s.getX(), s.getY()
247 bbox = subtracted.getBBox(afwImage.PARENT)
248 if bbox.contains(geom.PointI(int(xc), int(yc))):
249 measAlg.subtractPsf(psf, subtracted, xc, yc)
250 chi = subtracted.Factory(subtracted, True)
251 var = subtracted.getVariance()
252 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
253 chi /= var
255 chi_min = np.min(chi.getImage().getArray())
256 chi_max = np.max(chi.getImage().getArray())
258 if chi_lim > 0:
259 self.assertGreater(chi_min, -chi_lim)
260 self.assertLess(chi_max, chi_lim)
262 def checkPiffDeterminer(self, **kwargs):
263 """Configure PiffPsfDeterminerTask and run basic tests on it.
265 Parameters
266 ----------
267 kwargs : `dict`, optional
268 Additional keyword arguments to pass to setupDeterminer.
269 """
270 self.setupDeterminer(**kwargs)
271 metadata = dafBase.PropertyList()
273 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
274 psfCandidateList = self.makePsfCandidates.run(
275 stars.sourceCat,
276 exposure=self.exposure
277 ).psfCandidates
279 logger = logging.getLogger("lsst.psfDeterminer.Piff")
281 with self.assertLogs("lsst.psfDeterminer.Piff.piff", logging.WARNING) as cm:
282 with self.assertNoLogs("lsst.psfDeterminer.Piff", logging.WARNING):
283 psf, cellSet = self.psfDeterminer.determinePsf(
284 self.exposure,
285 psfCandidateList,
286 metadata,
287 flagKey=self.usePsfFlag
288 )
290 # Check that the iterations are being logged.
291 logged = "\n".join(cm.output)
292 self.assertRegex(logged, "WARNING:.*:Iteration")
294 # And check that the levels are set correctly for suppression.
295 logger = logging.getLogger("lsst.psfDeterminer.Piff.piff")
296 if kwargs.get("withlog", False):
297 self.assertEqual(logger.level, logging.WARNING)
298 else:
299 self.assertEqual(logger.level, logging.CRITICAL)
301 self.exposure.setPsf(psf)
303 if kwargs.get("downsample", False):
304 # When downsampling the PSF model is not quite as
305 # good so the chi2 test limit needs to be modified.
306 numAvail = self.psfDeterminer.config.maxCandidates
307 chiLim = 7.0
308 else:
309 numAvail = len(psfCandidateList)
310 chiLim = 6.1
312 self.assertEqual(metadata['numAvailStars'], numAvail)
313 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
314 self.assertLessEqual(metadata['numGoodStars'], metadata['numAvailStars'])
316 self.assertEqual(
317 psf.getAveragePosition(),
318 geom.Point2D(
319 np.mean([s.x for s in psf._piffResult.stars]),
320 np.mean([s.y for s in psf._piffResult.stars])
321 )
322 )
323 if self.psfDeterminer.config.debugStarData:
324 self.assertIn('image', psf._piffResult.stars[0].data.__dict__)
325 else:
326 self.assertNotIn('image', psf._piffResult.stars[0].data.__dict__)
328 # Test how well we can subtract the PSF model
329 self.subtractStars(self.exposure, self.catalog, chi_lim=chiLim)
331 # Test bboxes
332 for point in [
333 psf.getAveragePosition(),
334 geom.Point2D(),
335 geom.Point2D(1, 1)
336 ]:
337 self.assertEqual(
338 psf.computeBBox(point),
339 psf.computeKernelImage(point).getBBox()
340 )
341 self.assertEqual(
342 psf.computeKernelBBox(point),
343 psf.computeKernelImage(point).getBBox()
344 )
345 self.assertEqual(
346 psf.computeImageBBox(point),
347 psf.computeImage(point).getBBox()
348 )
350 # Some roundtrips
351 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
352 self.exposure.writeFits(tmpFile)
353 fitsIm = afwImage.ExposureF(tmpFile)
354 copyIm = copy.deepcopy(self.exposure)
356 for newIm in [fitsIm, copyIm]:
357 # Piff doesn't enable __eq__ for its results, so we just check
358 # that some PSF images come out the same.
359 for point in [
360 geom.Point2D(0, 0),
361 geom.Point2D(10, 100),
362 geom.Point2D(-200, 30),
363 geom.Point2D(float("nan")) # "nullPoint"
364 ]:
365 self.assertImagesAlmostEqual(
366 psf.computeImage(point),
367 newIm.getPsf().computeImage(point)
368 )
369 # Also check average position
370 newPsf = newIm.getPsf()
371 self.assertImagesAlmostEqual(
372 psf.computeImage(psf.getAveragePosition()),
373 newPsf.computeImage(newPsf.getAveragePosition())
374 )
376 def testPiffDeterminer_default(self):
377 """Test piff with the default config."""
378 self.checkPiffDeterminer()
380 def testPiffDeterminer_stampSize27(self):
381 """Test Piff with a psf stampSize of 27."""
382 self.checkPiffDeterminer(stampSize=27)
384 def testPiffDeterminer_debugStarData(self):
385 """Test Piff with debugStarData=True."""
386 self.checkPiffDeterminer(debugStarData=True)
388 def testPiffDeterminer_skyCoords(self):
389 """Test Piff sky coords."""
390 self.checkPiffDeterminer(useCoordinates='sky')
392 def testPiffDeterminer_downsample(self):
393 """Test Piff determiner with downsampling."""
394 self.checkPiffDeterminer(downsample=True)
396 def testPiffDeterminer_withlog(self):
397 """Test Piff determiner with chatty logs."""
398 self.checkPiffDeterminer(withlog=True)
401class PiffConfigTestCase(lsst.utils.tests.TestCase):
402 """A test case to check for valid Piff config"""
403 def testValidateGalsimInterpolant(self):
404 # Check that random strings are not valid interpolants.
405 self.assertFalse(_validateGalsimInterpolant("foo"))
406 # Check that the Lanczos order is an integer
407 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
408 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
409 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
410 # Check for various valid Lanczos interpolants
411 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
412 self.assertTrue(_validateGalsimInterpolant(interp))
413 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
414 # Evaluating the string should succeed. This is how Piff does it.
415 self.assertTrue(eval(interp))
416 # Check that interpolation methods are case sensitive.
417 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
418 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
419 self.assertFalse(_validateGalsimInterpolant(interp))
420 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
421 self.assertTrue(eval(f"galsim.{interp}"))
424class TestMemory(lsst.utils.tests.MemoryTestCase):
425 pass
428def setup_module(module):
429 lsst.utils.tests.init()
432if __name__ == "__main__": 432 ↛ 433line 432 didn't jump to line 433, because the condition on line 432 was never true
433 lsst.utils.tests.init()
434 unittest.main()