Coverage for tests/test_psf.py: 15%

190 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-22 11:51 +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/>. 

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

190 """Setup the starSelector and psfDeterminer 

191 

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 

208 

209 self.starSelector = starSelectorClass(config=starSelectorConfig) 

210 

211 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass() 

212 if stampSize is not None: 

213 makePsfCandidatesConfig.kernelSize = stampSize 

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

215 

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 

222 

223 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig) 

224 

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

228 

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 

239 

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

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

242 print(chi_min, chi_max) 

243 

244 if chi_lim > 0: 

245 self.assertGreater(chi_min, -chi_lim) 

246 self.assertLess(chi_max, chi_lim) 

247 

248 def checkPiffDeterminer(self, **kwargs): 

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

250 

251 Parameters 

252 ---------- 

253 kwargs : `dict`, optional 

254 Additional keyword arguments to pass to setupDeterminer. 

255 """ 

256 self.setupDeterminer(**kwargs) 

257 metadata = dafBase.PropertyList() 

258 

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) 

271 

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

285 

286 # Test how well we can subtract the PSF model 

287 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1) 

288 

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 ) 

307 

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) 

313 

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 ) 

333 

334 def testPiffDeterminer_default(self): 

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

336 self.checkPiffDeterminer() 

337 

338 def testPiffDeterminer_stampSize27(self): 

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

340 self.checkPiffDeterminer(stampSize=27) 

341 

342 def testPiffDeterminer_debugStarData(self): 

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

344 self.checkPiffDeterminer(debugStarData=True) 

345 

346 def testPiffDeterminer_skyCoords(self): 

347 """Test Piff sky coords.""" 

348 self.checkPiffDeterminer(useCoordinates='sky') 

349 

350 

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

372 

373 

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

375 pass 

376 

377 

378def setup_module(module): 

379 lsst.utils.tests.init() 

380 

381 

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