Coverage for tests/test_psf.py: 14%

200 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 04:18 -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/>. 

21 

22import galsim # noqa: F401 

23import unittest 

24import numpy as np 

25import copy 

26from galsim import Lanczos # noqa: F401 

27 

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 

40 

41 

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 

52 

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) 

55 

56 

57class SpatialModelPsfTestCase(lsst.utils.tests.TestCase): 

58 """A test case for SpatialModelPsf""" 

59 

60 def measure(self, footprintSet, exposure): 

61 """Measure a set of Footprints, returning a SourceCatalog""" 

62 catalog = afwTable.SourceCatalog(self.schema) 

63 

64 footprintSet.makeSources(catalog) 

65 

66 self.measureSources.run(catalog, exposure) 

67 return catalog 

68 

69 def setUp(self): 

70 config = SingleFrameMeasurementTask.ConfigClass() 

71 config.slots.apFlux = 'base_CircularApertureFlux_12_0' 

72 self.schema = afwTable.SourceTable.makeMinimalSchema() 

73 

74 self.measureSources = SingleFrameMeasurementTask( 

75 self.schema, config=config 

76 ) 

77 self.usePsfFlag = self.schema.addField("use_psf", type="Flag") 

78 

79 width, height = 110, 301 

80 

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") 

86 

87 self.ksize = 31 # size of desired kernel 

88 

89 sigma1 = 1.75 

90 sigma2 = 2*sigma1 

91 

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) 

101 

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()) 

114 

115 if sigma == sigma1: 

116 basisImage0 = basisImage 

117 else: 

118 basisImage -= basisImage0 

119 

120 basisKernelList.append(afwMath.FixedKernel(basisImage)) 

121 

122 order = 1 # 1 => up to linear 

123 spFunc = afwMath.PolynomialFunction2D(order) 

124 

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 ) 

130 

131 rand = afwMath.Random() # make these tests repeatable by setting seed 

132 

133 im = self.mi.getImage() 

134 afwMath.randomGaussianImage(im, rand) # N(0, 1) 

135 im *= sd # N(0, sd^2) 

136 

137 xarr, yarr = [], [] 

138 

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) 

148 

149 for x, y in zip(xarr, yarr): 

150 dx = rand.uniform() - 0.5 # random (centered) offsets 

151 dy = rand.uniform() - 0.5 

152 

153 k = exactKernel.getSpatialFunction(1)(x, y) 

154 b = (k*sigma1**2/((1 - k)*sigma2**2)) 

155 

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 

161 

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 

165 

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 

170 

171 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height)) 

172 self.cellSet = afwMath.SpatialCellSet(bbox, 100) 

173 

174 self.footprintSet = afwDetection.FootprintSet( 

175 self.mi, afwDetection.Threshold(100), "DETECTED" 

176 ) 

177 

178 self.catalog = self.measure(self.footprintSet, self.exposure) 

179 

180 for source in self.catalog: 

181 cand = measAlg.makePsfCandidate(source, self.exposure) 

182 self.cellSet.insertCandidate(cand) 

183 

184 def setupDeterminer( 

185 self, 

186 stampSize=None, 

187 debugStarData=False, 

188 useCoordinates='pixel', 

189 downsample=False, 

190 ): 

191 """Setup the starSelector and psfDeterminer 

192 

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 

209 

210 self.starSelector = starSelectorClass(config=starSelectorConfig) 

211 

212 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass() 

213 if stampSize is not None: 

214 makePsfCandidatesConfig.kernelSize = stampSize 

215 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig) 

216 

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 

225 

226 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig) 

227 

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() 

231 

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 

242 

243 chi_min = np.min(chi.getImage().getArray()) 

244 chi_max = np.max(chi.getImage().getArray()) 

245 print(chi_min, chi_max) 

246 

247 if chi_lim > 0: 

248 self.assertGreater(chi_min, -chi_lim) 

249 self.assertLess(chi_max, chi_lim) 

250 

251 def checkPiffDeterminer(self, **kwargs): 

252 """Configure PiffPsfDeterminerTask and run basic tests on it. 

253 

254 Parameters 

255 ---------- 

256 kwargs : `dict`, optional 

257 Additional keyword arguments to pass to setupDeterminer. 

258 """ 

259 self.setupDeterminer(**kwargs) 

260 metadata = dafBase.PropertyList() 

261 

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) 

274 

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 

283 

284 self.assertEqual(metadata['numAvailStars'], numAvail) 

285 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars']) 

286 self.assertLessEqual(metadata['numGoodStars'], metadata['numAvailStars']) 

287 

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__) 

299 

300 # Test how well we can subtract the PSF model 

301 self.subtractStars(self.exposure, self.catalog, chi_lim=chiLim) 

302 

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 ) 

321 

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) 

327 

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 ) 

347 

348 def testPiffDeterminer_default(self): 

349 """Test piff with the default config.""" 

350 self.checkPiffDeterminer() 

351 

352 def testPiffDeterminer_stampSize27(self): 

353 """Test Piff with a psf stampSize of 27.""" 

354 self.checkPiffDeterminer(stampSize=27) 

355 

356 def testPiffDeterminer_debugStarData(self): 

357 """Test Piff with debugStarData=True.""" 

358 self.checkPiffDeterminer(debugStarData=True) 

359 

360 def testPiffDeterminer_skyCoords(self): 

361 """Test Piff sky coords.""" 

362 self.checkPiffDeterminer(useCoordinates='sky') 

363 

364 def testPiffDeterminer_downsample(self): 

365 """Test Piff determiner with downsampling.""" 

366 self.checkPiffDeterminer(downsample=True) 

367 

368 

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}")) 

390 

391 

392class TestMemory(lsst.utils.tests.MemoryTestCase): 

393 pass 

394 

395 

396def setup_module(module): 

397 lsst.utils.tests.init() 

398 

399 

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()