Coverage for tests/test_psf.py: 14%
200 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-17 10:36 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-17 10:36 +0000
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
28import lsst.utils.tests
29import lsst.afw.detection as afwDetection
30import lsst.afw.geom as afwGeom
31import lsst.afw.image as afwImage
32import lsst.afw.math as afwMath
33import lsst.afw.table as afwTable
34import lsst.daf.base as dafBase
35import lsst.geom as geom
36import lsst.meas.algorithms as measAlg
37from lsst.meas.base import SingleFrameMeasurementTask
38from lsst.meas.extensions.piff.piffPsfDeterminer import PiffPsfDeterminerConfig, PiffPsfDeterminerTask
39from lsst.meas.extensions.piff.piffPsfDeterminer import _validateGalsimInterpolant
42def psfVal(ix, iy, x, y, sigma1, sigma2, b):
43 """Return the value at (ix, iy) of a double Gaussian
44 (N(0, sigma1^2) + b*N(0, sigma2^2))/(1 + b)
45 centered at (x, y)
46 """
47 dx, dy = x - ix, y - iy
48 theta = np.radians(30)
49 ab = 1.0/0.75 # axis ratio
50 c, s = np.cos(theta), np.sin(theta)
51 u, v = c*dx - s*dy, s*dx + c*dy
53 return (np.exp(-0.5*(u**2 + (v*ab)**2)/sigma1**2)
54 + b*np.exp(-0.5*(u**2 + (v*ab)**2)/sigma2**2))/(1 + b)
57class SpatialModelPsfTestCase(lsst.utils.tests.TestCase):
58 """A test case for SpatialModelPsf"""
60 def measure(self, footprintSet, exposure):
61 """Measure a set of Footprints, returning a SourceCatalog"""
62 catalog = afwTable.SourceCatalog(self.schema)
64 footprintSet.makeSources(catalog)
66 self.measureSources.run(catalog, exposure)
67 return catalog
69 def setUp(self):
70 config = SingleFrameMeasurementTask.ConfigClass()
71 config.slots.apFlux = 'base_CircularApertureFlux_12_0'
72 self.schema = afwTable.SourceTable.makeMinimalSchema()
74 self.measureSources = SingleFrameMeasurementTask(
75 self.schema, config=config
76 )
77 self.usePsfFlag = self.schema.addField("use_psf", type="Flag")
79 width, height = 110, 301
81 self.mi = afwImage.MaskedImageF(geom.ExtentI(width, height))
82 self.mi.set(0)
83 sd = 3 # standard deviation of image
84 self.mi.getVariance().set(sd*sd)
85 self.mi.getMask().addMaskPlane("DETECTED")
87 self.ksize = 31 # size of desired kernel
89 sigma1 = 1.75
90 sigma2 = 2*sigma1
92 self.exposure = afwImage.makeExposure(self.mi)
93 self.exposure.setPsf(measAlg.DoubleGaussianPsf(self.ksize, self.ksize,
94 1.5*sigma1, 1, 0.1))
95 cdMatrix = np.array([1.0, 0.0, 0.0, 1.0]) * 0.2/3600
96 cdMatrix.shape = (2, 2)
97 wcs = afwGeom.makeSkyWcs(crpix=geom.PointD(0, 0),
98 crval=geom.SpherePoint(0.0, 0.0, geom.degrees),
99 cdMatrix=cdMatrix)
100 self.exposure.setWcs(wcs)
102 #
103 # Make a kernel with the exactly correct basis functions.
104 # Useful for debugging
105 #
106 basisKernelList = []
107 for sigma in (sigma1, sigma2):
108 basisKernel = afwMath.AnalyticKernel(
109 self.ksize, self.ksize, afwMath.GaussianFunction2D(sigma, sigma)
110 )
111 basisImage = afwImage.ImageD(basisKernel.getDimensions())
112 basisKernel.computeImage(basisImage, True)
113 basisImage /= np.sum(basisImage.getArray())
115 if sigma == sigma1:
116 basisImage0 = basisImage
117 else:
118 basisImage -= basisImage0
120 basisKernelList.append(afwMath.FixedKernel(basisImage))
122 order = 1 # 1 => up to linear
123 spFunc = afwMath.PolynomialFunction2D(order)
125 exactKernel = afwMath.LinearCombinationKernel(basisKernelList, spFunc)
126 exactKernel.setSpatialParameters(
127 [[1.0, 0, 0],
128 [0.0, 0.5*1e-2, 0.2e-2]]
129 )
131 rand = afwMath.Random() # make these tests repeatable by setting seed
133 im = self.mi.getImage()
134 afwMath.randomGaussianImage(im, rand) # N(0, 1)
135 im *= sd # N(0, sd^2)
137 xarr, yarr = [], []
139 for x, y in [(20, 20), (60, 20),
140 (30, 35),
141 (50, 50),
142 (20, 90), (70, 160), (25, 265), (75, 275), (85, 30),
143 (50, 120), (70, 80),
144 (60, 210), (20, 210),
145 ]:
146 xarr.append(x)
147 yarr.append(y)
149 for x, y in zip(xarr, yarr):
150 dx = rand.uniform() - 0.5 # random (centered) offsets
151 dy = rand.uniform() - 0.5
153 k = exactKernel.getSpatialFunction(1)(x, y)
154 b = (k*sigma1**2/((1 - k)*sigma2**2))
156 flux = 80000*(1 + 0.1*(rand.uniform() - 0.5))
157 I0 = flux*(1 + b)/(2*np.pi*(sigma1**2 + b*sigma2**2))
158 for iy in range(y - self.ksize//2, y + self.ksize//2 + 1):
159 if iy < 0 or iy >= self.mi.getHeight():
160 continue
162 for ix in range(x - self.ksize//2, x + self.ksize//2 + 1):
163 if ix < 0 or ix >= self.mi.getWidth():
164 continue
166 II = I0*psfVal(ix, iy, x + dx, y + dy, sigma1, sigma2, b)
167 Isample = rand.poisson(II)
168 self.mi.image[ix, iy, afwImage.LOCAL] += Isample
169 self.mi.variance[ix, iy, afwImage.LOCAL] += II
171 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height))
172 self.cellSet = afwMath.SpatialCellSet(bbox, 100)
174 self.footprintSet = afwDetection.FootprintSet(
175 self.mi, afwDetection.Threshold(100), "DETECTED"
176 )
178 self.catalog = self.measure(self.footprintSet, self.exposure)
180 for source in self.catalog:
181 cand = measAlg.makePsfCandidate(source, self.exposure)
182 self.cellSet.insertCandidate(cand)
184 def setupDeterminer(
185 self,
186 stampSize=None,
187 debugStarData=False,
188 useCoordinates='pixel',
189 downsample=False,
190 ):
191 """Setup the starSelector and psfDeterminer
193 Parameters
194 ----------
195 stampSize : `int`, optional
196 Set ``config.stampSize`` to this, if not None.
197 """
198 starSelectorClass = measAlg.sourceSelectorRegistry["objectSize"]
199 starSelectorConfig = starSelectorClass.ConfigClass()
200 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
201 starSelectorConfig.badFlags = [
202 "base_PixelFlags_flag_edge",
203 "base_PixelFlags_flag_interpolatedCenter",
204 "base_PixelFlags_flag_saturatedCenter",
205 "base_PixelFlags_flag_crCenter",
206 ]
207 # Set to match when the tolerance of the test was set
208 starSelectorConfig.widthStdAllowed = 0.5
210 self.starSelector = starSelectorClass(config=starSelectorConfig)
212 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
213 if stampSize is not None:
214 makePsfCandidatesConfig.kernelSize = stampSize
215 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
217 psfDeterminerConfig = PiffPsfDeterminerConfig()
218 psfDeterminerConfig.spatialOrder = 1
219 if stampSize is not None:
220 psfDeterminerConfig.stampSize = stampSize
221 psfDeterminerConfig.debugStarData = debugStarData
222 psfDeterminerConfig.useCoordinates = useCoordinates
223 if downsample:
224 psfDeterminerConfig.maxCandidates = 10
226 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
228 def subtractStars(self, exposure, catalog, chi_lim=-1):
229 """Subtract the exposure's PSF from all the sources in catalog"""
230 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
232 subtracted = mi.Factory(mi, True)
233 for s in catalog:
234 xc, yc = s.getX(), s.getY()
235 bbox = subtracted.getBBox(afwImage.PARENT)
236 if bbox.contains(geom.PointI(int(xc), int(yc))):
237 measAlg.subtractPsf(psf, subtracted, xc, yc)
238 chi = subtracted.Factory(subtracted, True)
239 var = subtracted.getVariance()
240 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
241 chi /= var
243 chi_min = np.min(chi.getImage().getArray())
244 chi_max = np.max(chi.getImage().getArray())
245 print(chi_min, chi_max)
247 if chi_lim > 0:
248 self.assertGreater(chi_min, -chi_lim)
249 self.assertLess(chi_max, chi_lim)
251 def checkPiffDeterminer(self, **kwargs):
252 """Configure PiffPsfDeterminerTask and run basic tests on it.
254 Parameters
255 ----------
256 kwargs : `dict`, optional
257 Additional keyword arguments to pass to setupDeterminer.
258 """
259 self.setupDeterminer(**kwargs)
260 metadata = dafBase.PropertyList()
262 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
263 psfCandidateList = self.makePsfCandidates.run(
264 stars.sourceCat,
265 exposure=self.exposure
266 ).psfCandidates
267 psf, cellSet = self.psfDeterminer.determinePsf(
268 self.exposure,
269 psfCandidateList,
270 metadata,
271 flagKey=self.usePsfFlag
272 )
273 self.exposure.setPsf(psf)
275 if kwargs.get("downsample", False):
276 # When downsampling the PSF model is not quite as
277 # good so the chi2 test limit needs to be modified.
278 numAvail = self.psfDeterminer.config.maxCandidates
279 chiLim = 7.0
280 else:
281 numAvail = len(psfCandidateList)
282 chiLim = 6.1
284 self.assertEqual(metadata['numAvailStars'], numAvail)
285 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
286 self.assertLessEqual(metadata['numGoodStars'], metadata['numAvailStars'])
288 self.assertEqual(
289 psf.getAveragePosition(),
290 geom.Point2D(
291 np.mean([s.x for s in psf._piffResult.stars]),
292 np.mean([s.y for s in psf._piffResult.stars])
293 )
294 )
295 if self.psfDeterminer.config.debugStarData:
296 self.assertIn('image', psf._piffResult.stars[0].data.__dict__)
297 else:
298 self.assertNotIn('image', psf._piffResult.stars[0].data.__dict__)
300 # Test how well we can subtract the PSF model
301 self.subtractStars(self.exposure, self.catalog, chi_lim=chiLim)
303 # Test bboxes
304 for point in [
305 psf.getAveragePosition(),
306 geom.Point2D(),
307 geom.Point2D(1, 1)
308 ]:
309 self.assertEqual(
310 psf.computeBBox(point),
311 psf.computeKernelImage(point).getBBox()
312 )
313 self.assertEqual(
314 psf.computeKernelBBox(point),
315 psf.computeKernelImage(point).getBBox()
316 )
317 self.assertEqual(
318 psf.computeImageBBox(point),
319 psf.computeImage(point).getBBox()
320 )
322 # Some roundtrips
323 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
324 self.exposure.writeFits(tmpFile)
325 fitsIm = afwImage.ExposureF(tmpFile)
326 copyIm = copy.deepcopy(self.exposure)
328 for newIm in [fitsIm, copyIm]:
329 # Piff doesn't enable __eq__ for its results, so we just check
330 # that some PSF images come out the same.
331 for point in [
332 geom.Point2D(0, 0),
333 geom.Point2D(10, 100),
334 geom.Point2D(-200, 30),
335 geom.Point2D(float("nan")) # "nullPoint"
336 ]:
337 self.assertImagesAlmostEqual(
338 psf.computeImage(point),
339 newIm.getPsf().computeImage(point)
340 )
341 # Also check average position
342 newPsf = newIm.getPsf()
343 self.assertImagesAlmostEqual(
344 psf.computeImage(psf.getAveragePosition()),
345 newPsf.computeImage(newPsf.getAveragePosition())
346 )
348 def testPiffDeterminer_default(self):
349 """Test piff with the default config."""
350 self.checkPiffDeterminer()
352 def testPiffDeterminer_stampSize27(self):
353 """Test Piff with a psf stampSize of 27."""
354 self.checkPiffDeterminer(stampSize=27)
356 def testPiffDeterminer_debugStarData(self):
357 """Test Piff with debugStarData=True."""
358 self.checkPiffDeterminer(debugStarData=True)
360 def testPiffDeterminer_skyCoords(self):
361 """Test Piff sky coords."""
362 self.checkPiffDeterminer(useCoordinates='sky')
364 def testPiffDeterminer_downsample(self):
365 """Test Piff determiner with downsampling."""
366 self.checkPiffDeterminer(downsample=True)
369class PiffConfigTestCase(lsst.utils.tests.TestCase):
370 """A test case to check for valid Piff config"""
371 def testValidateGalsimInterpolant(self):
372 # Check that random strings are not valid interpolants.
373 self.assertFalse(_validateGalsimInterpolant("foo"))
374 # Check that the Lanczos order is an integer
375 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
376 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
377 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
378 # Check for various valid Lanczos interpolants
379 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
380 self.assertTrue(_validateGalsimInterpolant(interp))
381 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
382 # Evaluating the string should succeed. This is how Piff does it.
383 self.assertTrue(eval(interp))
384 # Check that interpolation methods are case sensitive.
385 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
386 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
387 self.assertFalse(_validateGalsimInterpolant(interp))
388 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
389 self.assertTrue(eval(f"galsim.{interp}"))
392class TestMemory(lsst.utils.tests.MemoryTestCase):
393 pass
396def setup_module(module):
397 lsst.utils.tests.init()
400if __name__ == "__main__": 400 ↛ 401line 400 didn't jump to line 401, because the condition on line 400 was never true
401 lsst.utils.tests.init()
402 unittest.main()