Coverage for tests/test_trailedSources.py: 25%

205 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-05-24 03:03 -0700

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 

36import lsst.log 

37 

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

39rng = np.random.default_rng(432) 

40nTrails = 50 

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

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

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

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

45 

46 

47class TrailedSource: 

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

49 """ 

50 

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

52 self.instFlux = instFlux 

53 self.length = length 

54 self.angle = angle 

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

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

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

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

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

60 

61 

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

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

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

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

66 Exposure. 

67 """ 

68 

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

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

71 

72 def addTrailedSource(self, trail): 

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

74 'Re-implemented' version of 

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

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

77 """ 

78 

79 record = self.catalog.addNew() 

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

81 rng = np.random.default_rng(32) 

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

83 covariance[0, 1] = covariance[1, 0] 

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

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

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

87 

88 # Sum the psf at each 

89 numIter = int(10*trail.length) 

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

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

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

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

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

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

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

97 

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

99 self.exposure.image.array /= totFlux 

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

101 

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

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

104 

105 return record, self.exposure.getImage() 

106 

107 

108# Following from meas_base/test_NaiveCentroid.py 

109# Taken from NaiveCentroidTestCase 

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

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

112 

113 def setUp(self): 

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

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

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

117 self.dataset = TrailedTestDataset(self.bbox) 

118 

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

120 self.dataset.addTrailedSource(self.trail) 

121 

122 def tearDown(self): 

123 del self.center 

124 del self.bbox 

125 del self.trail 

126 del self.dataset 

127 

128 @staticmethod 

129 def transformMoments(Ixx, Iyy, Ixy): 

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

131 """ 

132 xmy = Ixx - Iyy 

133 xpy = Ixx + Iyy 

134 xmy2 = xmy*xmy 

135 xy2 = Ixy*Ixy 

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

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

138 return a2, b2 

139 

140 @staticmethod 

141 def f_length(x): 

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

143 

144 @staticmethod 

145 def g_length(x): 

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

147 

148 @staticmethod 

149 def f_flux(x, model): 

150 return model.computeFluxWithGradient(x)[0] 

151 

152 @staticmethod 

153 def g_flux(x, model): 

154 return model.computeFluxWithGradient(x)[1] 

155 

156 @staticmethod 

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

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

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

160 xp = x.copy() 

161 xp[i] += h 

162 fp = func(xp, *args) 

163 

164 xm = x.copy() 

165 xm[i] -= h 

166 fm = func(xm, *args) 

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

168 

169 return result 

170 

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

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

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

174 """ 

175 

176 config = self.makeSingleFrameMeasurementConfig(plugin=plugin, 

177 dependencies=dependencies) 

178 

179 # Make sure the shape slot is base_SdssShape 

180 config.slots.shape = "base_SdssShape" 

181 return self.makeSingleFrameMeasurementTask(plugin=plugin, 

182 dependencies=dependencies, 

183 config=config, schema=schema, 

184 algMetadata=algMetadata) 

185 

186 def testNaivePlugin(self): 

187 """Test the NaivePlugin measurements. 

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

189 compare the measured parameters to the true values. 

190 """ 

191 

192 # Set up and run Naive measurement. 

193 task = self.makeTrailedSourceMeasurementTask( 

194 plugin="ext_trailedSources_Naive", 

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

196 ) 

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

198 task.run(catalog, exposure) 

199 record = catalog[0] 

200 

201 # Check the RA and Dec measurements 

202 wcs = exposure.getWcs() 

203 spt = wcs.pixelToSky(self.center) 

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

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

206 ra_meas = record.get("ext_trailedSources_Naive_ra") 

207 dec_meas = record.get("ext_trailedSources_Naive_dec") 

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

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

210 

211 # Check that root finder converged 

212 converged = record.get("ext_trailedSources_Naive_flag_noConverge") 

213 self.assertFalse(converged) 

214 

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

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

217 # rtol values are simply rough upper bounds. 

218 length = record.get("ext_trailedSources_Naive_length") 

219 theta = record.get("ext_trailedSources_Naive_angle") 

220 flux = record.get("ext_trailedSources_Naive_flux") 

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

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

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

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

225 

226 # Test function gradients versus finite difference derivatives 

227 # Do length first 

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

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

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

231 

232 # Now flux gradient 

233 xc = record.get("base_SdssShape_x") 

234 yc = record.get("base_SdssShape_y") 

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

236 cutout = getMeasurementCutout(record, exposure) 

237 model = VeresModel(cutout) 

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

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

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

241 

242 # Check test setup 

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

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

245 

246 # Make sure measurement flag is False 

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

248 

249 def testVeresPlugin(self): 

250 """Test the VeresPlugin measurements. 

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

252 compare the measured parameters to the true values. 

253 """ 

254 

255 # Set up and run Veres measurement. 

256 task = self.makeTrailedSourceMeasurementTask( 

257 plugin="ext_trailedSources_Veres", 

258 dependencies=( 

259 "base_SdssCentroid", 

260 "base_SdssShape", 

261 "ext_trailedSources_Naive") 

262 ) 

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

264 task.run(catalog, exposure) 

265 record = catalog[0] 

266 

267 # Make sure optmizer converged 

268 converged = record.get("ext_trailedSources_Veres_flag_nonConvergence") 

269 self.assertFalse(converged) 

270 

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

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

273 length = record.get("ext_trailedSources_Veres_length") 

274 theta = record.get("ext_trailedSources_Veres_angle") 

275 flux = record.get("ext_trailedSources_Veres_flux") 

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

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

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

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

280 

281 xc = record.get("ext_trailedSources_Veres_centroid_x") 

282 yc = record.get("ext_trailedSources_Veres_centroid_y") 

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

284 cutout = getMeasurementCutout(record, exposure) 

285 model = VeresModel(cutout) 

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

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

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

289 

290 # Make sure test setup is working as expected 

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

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

293 

294 # Test that reduced chi-squared is reasonable 

295 rChiSq = record.get("ext_trailedSources_Veres_rChiSq") 

296 self.assertGreater(rChiSq, 0.8) 

297 self.assertLess(rChiSq, 1.3) 

298 

299 # Make sure measurement flag is False 

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

301 

302 def testMonteCarlo(self): 

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

304 """ 

305 # Adapted from lsst.meas.base 

306 

307 # Set up Naive measurement and dependencies. 

308 task = self.makeTrailedSourceMeasurementTask( 

309 plugin="ext_trailedSources_Naive", 

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

311 ) 

312 

313 nSamples = 2000 

314 catalog = afwTable.SourceCatalog(task.schema) 

315 sample = 0 

316 seed = 0 

317 while sample < nSamples: 

318 seed += 1 

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

320 rec = cat[0] 

321 task.run(cat, exp) 

322 

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

324 # centroiding. Skip when shape measurement fails. 

325 if rec['base_SdssShape_flag']: 

326 continue 

327 catalog.append(rec) 

328 sample += 1 

329 

330 catalog = catalog.copy(deep=True) 

331 nameBase = "ext_trailedSources_Naive_" 

332 

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

334 # we're close or at least over estimate 

335 length = catalog[nameBase+"length"] 

336 lengthErr = catalog[nameBase+"lengthErr"] 

337 lengthStd = np.nanstd(length) 

338 lengthErrMean = np.nanmean(lengthErr) 

339 diff = (lengthErrMean - lengthStd) / lengthErrMean 

340 self.assertGreater(diff, -0.1) 

341 self.assertLess(diff, 0.5) 

342 

343 angle = catalog[nameBase+"angle"] 

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

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

346 angleErr = catalog[nameBase+"angleErr"] 

347 angleStd = np.nanstd(angle) 

348 angleErrMean = np.nanmean(angleErr) 

349 diff = (angleErrMean - angleStd) / angleErrMean 

350 self.assertGreater(diff, -0.1) 

351 self.assertLess(diff, 0.6) 

352 

353 

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

355 pass 

356 

357 

358def setup_module(module): 

359 lsst.utils.tests.init() 

360 

361 

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

363 lsst.utils.tests.init() 

364 unittest.main()