Coverage for tests/test_psf.py: 15%
190 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 04:53 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 04:53 -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
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 ):
190 """Setup the starSelector and psfDeterminer
192 Parameters
193 ----------
194 stampSize : `int`, optional
195 Set ``config.stampSize`` to this, if not None.
196 """
197 starSelectorClass = measAlg.sourceSelectorRegistry["objectSize"]
198 starSelectorConfig = starSelectorClass.ConfigClass()
199 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
200 starSelectorConfig.badFlags = [
201 "base_PixelFlags_flag_edge",
202 "base_PixelFlags_flag_interpolatedCenter",
203 "base_PixelFlags_flag_saturatedCenter",
204 "base_PixelFlags_flag_crCenter",
205 ]
206 # Set to match when the tolerance of the test was set
207 starSelectorConfig.widthStdAllowed = 0.5
209 self.starSelector = starSelectorClass(config=starSelectorConfig)
211 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
212 if stampSize is not None:
213 makePsfCandidatesConfig.kernelSize = stampSize
214 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
216 psfDeterminerConfig = PiffPsfDeterminerConfig()
217 psfDeterminerConfig.spatialOrder = 1
218 if stampSize is not None:
219 psfDeterminerConfig.stampSize = stampSize
220 psfDeterminerConfig.debugStarData = debugStarData
221 psfDeterminerConfig.useCoordinates = useCoordinates
223 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
225 def subtractStars(self, exposure, catalog, chi_lim=-1):
226 """Subtract the exposure's PSF from all the sources in catalog"""
227 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
229 subtracted = mi.Factory(mi, True)
230 for s in catalog:
231 xc, yc = s.getX(), s.getY()
232 bbox = subtracted.getBBox(afwImage.PARENT)
233 if bbox.contains(geom.PointI(int(xc), int(yc))):
234 measAlg.subtractPsf(psf, subtracted, xc, yc)
235 chi = subtracted.Factory(subtracted, True)
236 var = subtracted.getVariance()
237 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
238 chi /= var
240 chi_min = np.min(chi.getImage().getArray())
241 chi_max = np.max(chi.getImage().getArray())
242 print(chi_min, chi_max)
244 if chi_lim > 0:
245 self.assertGreater(chi_min, -chi_lim)
246 self.assertLess(chi_max, chi_lim)
248 def checkPiffDeterminer(self, **kwargs):
249 """Configure PiffPsfDeterminerTask and run basic tests on it.
251 Parameters
252 ----------
253 kwargs : `dict`, optional
254 Additional keyword arguments to pass to setupDeterminer.
255 """
256 self.setupDeterminer(**kwargs)
257 metadata = dafBase.PropertyList()
259 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
260 psfCandidateList = self.makePsfCandidates.run(
261 stars.sourceCat,
262 exposure=self.exposure
263 ).psfCandidates
264 psf, cellSet = self.psfDeterminer.determinePsf(
265 self.exposure,
266 psfCandidateList,
267 metadata,
268 flagKey=self.usePsfFlag
269 )
270 self.exposure.setPsf(psf)
272 self.assertEqual(len(psfCandidateList), metadata['numAvailStars'])
273 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
274 self.assertEqual(
275 psf.getAveragePosition(),
276 geom.Point2D(
277 np.mean([s.x for s in psf._piffResult.stars]),
278 np.mean([s.y for s in psf._piffResult.stars])
279 )
280 )
281 if self.psfDeterminer.config.debugStarData:
282 self.assertIn('image', psf._piffResult.stars[0].data.__dict__)
283 else:
284 self.assertNotIn('image', psf._piffResult.stars[0].data.__dict__)
286 # Test how well we can subtract the PSF model
287 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1)
289 # Test bboxes
290 for point in [
291 psf.getAveragePosition(),
292 geom.Point2D(),
293 geom.Point2D(1, 1)
294 ]:
295 self.assertEqual(
296 psf.computeBBox(point),
297 psf.computeKernelImage(point).getBBox()
298 )
299 self.assertEqual(
300 psf.computeKernelBBox(point),
301 psf.computeKernelImage(point).getBBox()
302 )
303 self.assertEqual(
304 psf.computeImageBBox(point),
305 psf.computeImage(point).getBBox()
306 )
308 # Some roundtrips
309 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
310 self.exposure.writeFits(tmpFile)
311 fitsIm = afwImage.ExposureF(tmpFile)
312 copyIm = copy.deepcopy(self.exposure)
314 for newIm in [fitsIm, copyIm]:
315 # Piff doesn't enable __eq__ for its results, so we just check
316 # that some PSF images come out the same.
317 for point in [
318 geom.Point2D(0, 0),
319 geom.Point2D(10, 100),
320 geom.Point2D(-200, 30),
321 geom.Point2D(float("nan")) # "nullPoint"
322 ]:
323 self.assertImagesAlmostEqual(
324 psf.computeImage(point),
325 newIm.getPsf().computeImage(point)
326 )
327 # Also check average position
328 newPsf = newIm.getPsf()
329 self.assertImagesAlmostEqual(
330 psf.computeImage(psf.getAveragePosition()),
331 newPsf.computeImage(newPsf.getAveragePosition())
332 )
334 def testPiffDeterminer_default(self):
335 """Test piff with the default config."""
336 self.checkPiffDeterminer()
338 def testPiffDeterminer_stampSize27(self):
339 """Test Piff with a psf stampSize of 27."""
340 self.checkPiffDeterminer(stampSize=27)
342 def testPiffDeterminer_debugStarData(self):
343 """Test Piff with debugStarData=True."""
344 self.checkPiffDeterminer(debugStarData=True)
346 def testPiffDeterminer_skyCoords(self):
347 """Test Piff sky coords."""
348 self.checkPiffDeterminer(useCoordinates='sky')
351class PiffConfigTestCase(lsst.utils.tests.TestCase):
352 """A test case to check for valid Piff config"""
353 def testValidateGalsimInterpolant(self):
354 # Check that random strings are not valid interpolants.
355 self.assertFalse(_validateGalsimInterpolant("foo"))
356 # Check that the Lanczos order is an integer
357 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
358 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
359 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
360 # Check for various valid Lanczos interpolants
361 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
362 self.assertTrue(_validateGalsimInterpolant(interp))
363 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
364 # Evaluating the string should succeed. This is how Piff does it.
365 self.assertTrue(eval(interp))
366 # Check that interpolation methods are case sensitive.
367 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
368 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
369 self.assertFalse(_validateGalsimInterpolant(interp))
370 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
371 self.assertTrue(eval(f"galsim.{interp}"))
374class TestMemory(lsst.utils.tests.MemoryTestCase):
375 pass
378def setup_module(module):
379 lsst.utils.tests.init()
382if __name__ == "__main__": 382 ↛ 383line 382 didn't jump to line 383, because the condition on line 382 was never true
383 lsst.utils.tests.init()
384 unittest.main()