Coverage for tests/test_trailedSources.py: 27%

204 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-28 10:48 +0000

1# 

2# This file is part of meas_extensions_trailedSources. 

3# 

4# Developed for the LSST Data Management System. 

5# This product includes software developed by the LSST Project 

6# (http://www.lsst.org). 

7# See the COPYRIGHT file at the top-level directory of this distribution 

8# for details of code ownership. 

9# 

10# This program is free software: you can redistribute it and/or modify 

11# it under the terms of the GNU General Public License as published by 

12# the Free Software Foundation, either version 3 of the License, or 

13# (at your option) any later version. 

14# 

15# This program is distributed in the hope that it will be useful, 

16# but WITHOUT ANY WARRANTY; without even the implied warranty of 

17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

18# GNU General Public License for more details. 

19# 

20# You should have received a copy of the GNU General Public License 

21# along with this program. If not, see <http://www.gnu.org/licenses/>. 

22# 

23 

24import numpy as np 

25import unittest 

26import lsst.utils.tests 

27import lsst.meas.extensions.trailedSources 

28from scipy.optimize import check_grad 

29import lsst.afw.table as afwTable 

30from lsst.meas.base.tests import AlgorithmTestCase 

31from lsst.meas.extensions.trailedSources import SingleFrameNaiveTrailPlugin as sfntp 

32from lsst.meas.extensions.trailedSources import VeresModel 

33from lsst.meas.extensions.trailedSources.utils import getMeasurementCutout 

34from lsst.utils.tests import classParameters 

35 

36 

37# Trailed-source length, angle, and centroid. 

38rng = np.random.default_rng(432) 

39nTrails = 50 

40Ls = rng.uniform(2, 20, nTrails) 

41thetas = rng.uniform(0, 2*np.pi, nTrails) 

42xcs = rng.uniform(0, 100, nTrails) 

43ycs = rng.uniform(0, 100, nTrails) 

44 

45 

46class TrailedSource: 

47 """Holds a set of true trail parameters. 

48 """ 

49 

50 def __init__(self, instFlux, length, angle, xc, yc): 

51 self.instFlux = instFlux 

52 self.length = length 

53 self.angle = angle 

54 self.center = lsst.geom.Point2D(xc, yc) 

55 self.x0 = xc - length/2 * np.cos(angle) 

56 self.y0 = yc - length/2 * np.sin(angle) 

57 self.x1 = xc + length/2 * np.cos(angle) 

58 self.y1 = yc + length/2 * np.sin(angle) 

59 

60 

61# "Extend" meas.base.tests.TestDataset 

62class TrailedTestDataset(lsst.meas.base.tests.TestDataset): 

63 """A dataset for testing trailed source measurements. 

64 Given a `TrailedSource`, construct a record of the true values and an 

65 Exposure. 

66 """ 

67 

68 def __init__(self, bbox, threshold=10.0, exposure=None, **kwds): 

69 super().__init__(bbox, threshold, exposure, **kwds) 

70 

71 def addTrailedSource(self, trail): 

72 """Add a trailed source to the simulation. 

73 'Re-implemented' version of 

74 `lsst.meas.base.tests.TestDataset.addSource`. Numerically integrates a 

75 Gaussian PSF over a line to obtain am image of a trailed source. 

76 """ 

77 

78 record = self.catalog.addNew() 

79 record.set(self.keys["centroid"], trail.center) 

80 rng = np.random.default_rng(32) 

81 covariance = rng.normal(0, 0.1, 4).reshape(2, 2) 

82 covariance[0, 1] = covariance[1, 0] 

83 record.set(self.keys["centroid_sigma"], covariance.astype(np.float32)) 

84 record.set(self.keys["shape"], self.psfShape) 

85 record.set(self.keys["isStar"], False) 

86 

87 # Sum the psf at each 

88 numIter = int(10*trail.length) 

89 xp = np.linspace(trail.x0, trail.x1, num=numIter) 

90 yp = np.linspace(trail.y0, trail.y1, num=numIter) 

91 for (x, y) in zip(xp, yp): 

92 pt = lsst.geom.Point2D(x, y) 

93 im = self.drawGaussian(self.exposure.getBBox(), trail.instFlux, 

94 lsst.afw.geom.Ellipse(self.psfShape, pt)) 

95 self.exposure.getMaskedImage().getImage().getArray()[:, :] += im.getArray() 

96 

97 totFlux = self.exposure.image.array.sum() 

98 self.exposure.image.array /= totFlux 

99 self.exposure.image.array *= trail.instFlux 

100 

101 record.set(self.keys["instFlux"], trail.instFlux) 

102 self._installFootprint(record, self.exposure.getImage()) 

103 

104 return record, self.exposure.getImage() 

105 

106 

107# Following from meas_base/test_NaiveCentroid.py 

108# Taken from NaiveCentroidTestCase 

109@classParameters(length=Ls, theta=thetas, xc=xcs, yc=ycs) 

110class TrailedSourcesTestCase(AlgorithmTestCase, lsst.utils.tests.TestCase): 

111 

112 def setUp(self): 

113 self.center = lsst.geom.Point2D(50.1, 49.8) 

114 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(-20, -30), 

115 lsst.geom.Extent2I(140, 160)) 

116 self.dataset = TrailedTestDataset(self.bbox) 

117 

118 self.trail = TrailedSource(100000.0, self.length, self.theta, self.xc, self.yc) 

119 self.dataset.addTrailedSource(self.trail) 

120 

121 def tearDown(self): 

122 del self.center 

123 del self.bbox 

124 del self.trail 

125 del self.dataset 

126 

127 @staticmethod 

128 def transformMoments(Ixx, Iyy, Ixy): 

129 """Transform second-moments to semi-major and minor axis. 

130 """ 

131 xmy = Ixx - Iyy 

132 xpy = Ixx + Iyy 

133 xmy2 = xmy*xmy 

134 xy2 = Ixy*Ixy 

135 a2 = 0.5 * (xpy + np.sqrt(xmy2 + 4.0*xy2)) 

136 b2 = 0.5 * (xpy - np.sqrt(xmy2 + 4.0*xy2)) 

137 return a2, b2 

138 

139 @staticmethod 

140 def f_length(x): 

141 return sfntp.findLength(*x)[0] 

142 

143 @staticmethod 

144 def g_length(x): 

145 return sfntp.findLength(*x)[1] 

146 

147 @staticmethod 

148 def f_flux(x, model): 

149 return model.computeFluxWithGradient(x)[0] 

150 

151 @staticmethod 

152 def g_flux(x, model): 

153 return model.computeFluxWithGradient(x)[1] 

154 

155 @staticmethod 

156 def central_difference(func, x, *args, h=1e-8): 

157 result = np.zeros(len(x)) 

158 for i in range(len(x)): 

159 xp = x.copy() 

160 xp[i] += h 

161 fp = func(xp, *args) 

162 

163 xm = x.copy() 

164 xm[i] -= h 

165 fm = func(xm, *args) 

166 result[i] = (fp - fm) / (2*h) 

167 

168 return result 

169 

170 def makeTrailedSourceMeasurementTask(self, plugin=None, dependencies=(), 

171 config=None, schema=None, algMetadata=None): 

172 """Set up a measurement task for a trailed source plugin. 

173 """ 

174 

175 config = self.makeSingleFrameMeasurementConfig(plugin=plugin, 

176 dependencies=dependencies) 

177 

178 # Make sure the shape slot is base_SdssShape 

179 config.slots.shape = "base_SdssShape" 

180 return self.makeSingleFrameMeasurementTask(plugin=plugin, 

181 dependencies=dependencies, 

182 config=config, schema=schema, 

183 algMetadata=algMetadata) 

184 

185 def testNaivePlugin(self): 

186 """Test the NaivePlugin measurements. 

187 Given a `TrailedTestDataset`, run the NaivePlugin measurement and 

188 compare the measured parameters to the true values. 

189 """ 

190 

191 # Set up and run Naive measurement. 

192 task = self.makeTrailedSourceMeasurementTask( 

193 plugin="ext_trailedSources_Naive", 

194 dependencies=("base_SdssCentroid", "base_SdssShape") 

195 ) 

196 exposure, catalog = self.dataset.realize(10.0, task.schema, randomSeed=0) 

197 task.run(catalog, exposure) 

198 record = catalog[0] 

199 

200 # Check the RA and Dec measurements 

201 wcs = exposure.getWcs() 

202 spt = wcs.pixelToSky(self.center) 

203 ra_true = spt.getRa().asDegrees() 

204 dec_true = spt.getDec().asDegrees() 

205 ra_meas = record.get("ext_trailedSources_Naive_ra") 

206 dec_meas = record.get("ext_trailedSources_Naive_dec") 

207 self.assertFloatsAlmostEqual(ra_true, ra_meas, atol=None, rtol=0.01) 

208 self.assertFloatsAlmostEqual(dec_true, dec_meas, atol=None, rtol=0.01) 

209 

210 # Check that root finder converged 

211 converged = record.get("ext_trailedSources_Naive_flag_noConverge") 

212 self.assertFalse(converged) 

213 

214 # Compare true with measured length, angle, and flux. 

215 # Accuracy is dependent on the second-moments measurements, so the 

216 # rtol values are simply rough upper bounds. 

217 length = record.get("ext_trailedSources_Naive_length") 

218 theta = record.get("ext_trailedSources_Naive_angle") 

219 flux = record.get("ext_trailedSources_Naive_flux") 

220 self.assertFloatsAlmostEqual(length, self.trail.length, atol=None, rtol=0.1) 

221 self.assertFloatsAlmostEqual(theta % np.pi, self.trail.angle % np.pi, 

222 atol=np.arctan(1/length), rtol=None) 

223 self.assertFloatsAlmostEqual(flux, self.trail.instFlux, atol=None, rtol=0.1) 

224 

225 # Test function gradients versus finite difference derivatives 

226 # Do length first 

227 Ixx, Iyy, Ixy = record.getShape().getParameterVector() 

228 a2, b2 = self.transformMoments(Ixx, Iyy, Ixy) 

229 self.assertLessEqual(check_grad(self.f_length, self.g_length, [a2, b2]), 1e-6) 

230 

231 # Now flux gradient 

232 xc = record.get("base_SdssShape_x") 

233 yc = record.get("base_SdssShape_y") 

234 params = np.array([xc, yc, 1.0, length, theta]) 

235 cutout = getMeasurementCutout(record, exposure) 

236 model = VeresModel(cutout) 

237 gradNum = self.central_difference(self.f_flux, params, model, h=9e-5) 

238 gradMax = np.max(np.abs(gradNum - self.g_flux(params, model))) 

239 self.assertLessEqual(gradMax, 1e-5) 

240 

241 # Check test setup 

242 self.assertNotEqual(length, self.trail.length) 

243 self.assertNotEqual(theta, self.trail.angle) 

244 

245 # Make sure measurement flag is False 

246 self.assertFalse(record.get("ext_trailedSources_Naive_flag")) 

247 

248 def testVeresPlugin(self): 

249 """Test the VeresPlugin measurements. 

250 Given a `TrailedTestDataset`, run the VeresPlugin measurement and 

251 compare the measured parameters to the true values. 

252 """ 

253 

254 # Set up and run Veres measurement. 

255 task = self.makeTrailedSourceMeasurementTask( 

256 plugin="ext_trailedSources_Veres", 

257 dependencies=( 

258 "base_SdssCentroid", 

259 "base_SdssShape", 

260 "ext_trailedSources_Naive") 

261 ) 

262 exposure, catalog = self.dataset.realize(10.0, task.schema, randomSeed=0) 

263 task.run(catalog, exposure) 

264 record = catalog[0] 

265 

266 # Make sure optmizer converged 

267 converged = record.get("ext_trailedSources_Veres_flag_nonConvergence") 

268 self.assertFalse(converged) 

269 

270 # Compare measured trail length, angle, and flux to true values 

271 # These measurements should perform at least as well as NaivePlugin 

272 length = record.get("ext_trailedSources_Veres_length") 

273 theta = record.get("ext_trailedSources_Veres_angle") 

274 flux = record.get("ext_trailedSources_Veres_flux") 

275 self.assertFloatsAlmostEqual(length, self.trail.length, atol=None, rtol=0.1) 

276 self.assertFloatsAlmostEqual(theta % np.pi, self.trail.angle % np.pi, 

277 atol=np.arctan(1/length), rtol=None) 

278 self.assertFloatsAlmostEqual(flux, self.trail.instFlux, atol=None, rtol=0.1) 

279 

280 xc = record.get("ext_trailedSources_Veres_centroid_x") 

281 yc = record.get("ext_trailedSources_Veres_centroid_y") 

282 params = np.array([xc, yc, flux, length, theta]) 

283 cutout = getMeasurementCutout(record, exposure) 

284 model = VeresModel(cutout) 

285 gradNum = self.central_difference(model, params, h=1e-6) 

286 gradMax = np.max(np.abs(gradNum - model.gradient(params))) 

287 self.assertLessEqual(gradMax, 1e-5) 

288 

289 # Make sure test setup is working as expected 

290 self.assertNotEqual(length, self.trail.length) 

291 self.assertNotEqual(theta, self.trail.angle) 

292 

293 # Test that reduced chi-squared is reasonable 

294 rChiSq = record.get("ext_trailedSources_Veres_rChiSq") 

295 self.assertGreater(rChiSq, 0.8) 

296 self.assertLess(rChiSq, 1.3) 

297 

298 # Make sure measurement flag is False 

299 self.assertFalse(record.get("ext_trailedSources_Veres_flag")) 

300 

301 def testMonteCarlo(self): 

302 """Test the uncertainties in trail measurements from NaivePlugin 

303 """ 

304 # Adapted from lsst.meas.base 

305 

306 # Set up Naive measurement and dependencies. 

307 task = self.makeTrailedSourceMeasurementTask( 

308 plugin="ext_trailedSources_Naive", 

309 dependencies=("base_SdssCentroid", "base_SdssShape") 

310 ) 

311 

312 nSamples = 2000 

313 catalog = afwTable.SourceCatalog(task.schema) 

314 sample = 0 

315 seed = 0 

316 while sample < nSamples: 

317 seed += 1 

318 exp, cat = self.dataset.realize(100.0, task.schema, randomSeed=seed) 

319 rec = cat[0] 

320 task.run(cat, exp) 

321 

322 # Accuracy of this measurement is entirely dependent on shape and 

323 # centroiding. Skip when shape measurement fails. 

324 if rec['base_SdssShape_flag']: 

325 continue 

326 catalog.append(rec) 

327 sample += 1 

328 

329 catalog = catalog.copy(deep=True) 

330 nameBase = "ext_trailedSources_Naive_" 

331 

332 # Currently, the errors don't include covariances, so just make sure 

333 # we're close or at least over estimate 

334 length = catalog[nameBase+"length"] 

335 lengthErr = catalog[nameBase+"lengthErr"] 

336 lengthStd = np.nanstd(length) 

337 lengthErrMean = np.nanmean(lengthErr) 

338 diff = (lengthErrMean - lengthStd) / lengthErrMean 

339 self.assertGreater(diff, -0.1) 

340 self.assertLess(diff, 0.5) 

341 

342 angle = catalog[nameBase+"angle"] 

343 if (np.max(angle) - np.min(angle)) > np.pi/2: 

344 angle = angle % np.pi # Wrap if bimodal 

345 angleErr = catalog[nameBase+"angleErr"] 

346 angleStd = np.nanstd(angle) 

347 angleErrMean = np.nanmean(angleErr) 

348 diff = (angleErrMean - angleStd) / angleErrMean 

349 self.assertGreater(diff, -0.1) 

350 self.assertLess(diff, 0.6) 

351 

352 

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

354 pass 

355 

356 

357def setup_module(module): 

358 lsst.utils.tests.init() 

359 

360 

361if __name__ == "__main__": 361 ↛ 362line 361 didn't jump to line 362, because the condition on line 361 was never true

362 lsst.utils.tests.init() 

363 unittest.main()