Coverage for tests/test_psf.py: 14%

214 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-11 04:13 -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 

27import logging 

28 

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 

41 

42 

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 

53 

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) 

56 

57 

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

59 """A test case for SpatialModelPsf""" 

60 

61 def measure(self, footprintSet, exposure): 

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

63 catalog = afwTable.SourceCatalog(self.schema) 

64 

65 footprintSet.makeSources(catalog) 

66 

67 self.measureSources.run(catalog, exposure) 

68 return catalog 

69 

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

82 

83 self.measureSources = SingleFrameMeasurementTask( 

84 self.schema, config=config 

85 ) 

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

87 

88 width, height = 110, 301 

89 

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

95 

96 self.ksize = 31 # size of desired kernel 

97 

98 sigma1 = 1.75 

99 sigma2 = 2*sigma1 

100 

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) 

110 

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

123 

124 if sigma == sigma1: 

125 basisImage0 = basisImage 

126 else: 

127 basisImage -= basisImage0 

128 

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

130 

131 order = 1 # 1 => up to linear 

132 spFunc = afwMath.PolynomialFunction2D(order) 

133 

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 ) 

139 

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

141 

142 im = self.mi.getImage() 

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

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

145 

146 xarr, yarr = [], [] 

147 

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) 

157 

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

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

160 dy = rand.uniform() - 0.5 

161 

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

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

164 

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 

170 

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 

174 

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 

179 

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

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

182 

183 self.footprintSet = afwDetection.FootprintSet( 

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

185 ) 

186 

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

188 

189 for source in self.catalog: 

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

191 self.cellSet.insertCandidate(cand) 

192 

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 

202 

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 

219 

220 self.starSelector = starSelectorClass(config=starSelectorConfig) 

221 

222 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass() 

223 if stampSize is not None: 

224 makePsfCandidatesConfig.kernelSize = stampSize 

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

226 

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 

237 

238 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig) 

239 

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

243 

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 

254 

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

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

257 

258 if chi_lim > 0: 

259 self.assertGreater(chi_min, -chi_lim) 

260 self.assertLess(chi_max, chi_lim) 

261 

262 def checkPiffDeterminer(self, **kwargs): 

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

264 

265 Parameters 

266 ---------- 

267 kwargs : `dict`, optional 

268 Additional keyword arguments to pass to setupDeterminer. 

269 """ 

270 self.setupDeterminer(**kwargs) 

271 metadata = dafBase.PropertyList() 

272 

273 stars = self.starSelector.run(self.catalog, exposure=self.exposure) 

274 psfCandidateList = self.makePsfCandidates.run( 

275 stars.sourceCat, 

276 exposure=self.exposure 

277 ).psfCandidates 

278 

279 logger = logging.getLogger("lsst.psfDeterminer.Piff") 

280 

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 ) 

289 

290 # Check that the iterations are being logged. 

291 logged = "\n".join(cm.output) 

292 self.assertRegex(logged, "WARNING:.*:Iteration") 

293 

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) 

300 

301 self.exposure.setPsf(psf) 

302 

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 

311 

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

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

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

315 

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

327 

328 # Test how well we can subtract the PSF model 

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

330 

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 ) 

349 

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) 

355 

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 ) 

375 

376 def testPiffDeterminer_default(self): 

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

378 self.checkPiffDeterminer() 

379 

380 def testPiffDeterminer_stampSize27(self): 

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

382 self.checkPiffDeterminer(stampSize=27) 

383 

384 def testPiffDeterminer_debugStarData(self): 

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

386 self.checkPiffDeterminer(debugStarData=True) 

387 

388 def testPiffDeterminer_skyCoords(self): 

389 """Test Piff sky coords.""" 

390 self.checkPiffDeterminer(useCoordinates='sky') 

391 

392 def testPiffDeterminer_downsample(self): 

393 """Test Piff determiner with downsampling.""" 

394 self.checkPiffDeterminer(downsample=True) 

395 

396 def testPiffDeterminer_withlog(self): 

397 """Test Piff determiner with chatty logs.""" 

398 self.checkPiffDeterminer(withlog=True) 

399 

400 

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

422 

423 

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

425 pass 

426 

427 

428def setup_module(module): 

429 lsst.utils.tests.init() 

430 

431 

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