Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# This file is part of jointcal. 

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 itertools 

23import unittest 

24from unittest import mock 

25 

26import numpy as np 

27 

28import lsst.log 

29import lsst.utils 

30 

31import lsst.afw.table 

32import lsst.daf.persistence 

33from lsst.daf.base import DateTime 

34import lsst.geom 

35from lsst.meas.algorithms import getRefFluxField, LoadIndexedReferenceObjectsTask, DatasetConfig 

36import lsst.pipe.base 

37import lsst.jointcal 

38from lsst.jointcal import MinimizeResult 

39import lsst.jointcal.chi2 

40import lsst.jointcal.testUtils 

41 

42 

43# for MemoryTestCase 

44def setup_module(module): 

45 lsst.utils.tests.init() 

46 

47 

48def make_fake_refcat(center, flux, filterName): 

49 """Make a fake reference catalog.""" 

50 schema = LoadIndexedReferenceObjectsTask.makeMinimalSchema([filterName], 

51 addProperMotion=True) 

52 catalog = lsst.afw.table.SimpleCatalog(schema) 

53 record = catalog.addNew() 

54 record.setCoord(center) 

55 record[filterName + '_flux'] = flux 

56 record[filterName + '_fluxErr'] = flux*0.1 

57 record['pm_ra'] = lsst.geom.Angle(1) 

58 record['pm_dec'] = lsst.geom.Angle(2) 

59 record['epoch'] = 65432.1 

60 return catalog 

61 

62 

63def make_fake_wcs(): 

64 """Return two simple SkyWcs objects, with slightly different sky positions. 

65 

66 Use the same pixel origins as the cfht_minimal data, but put the sky origin 

67 at RA=0 

68 """ 

69 crpix = lsst.geom.Point2D(931.517869, 2438.572109) 

70 cd = np.array([[5.19513851e-05, -2.81124812e-07], 

71 [-3.25186974e-07, -5.19112119e-05]]) 

72 crval1 = lsst.geom.SpherePoint(0.01, -0.01, lsst.geom.degrees) 

73 crval2 = lsst.geom.SpherePoint(-0.01, 0.01, lsst.geom.degrees) 

74 wcs1 = lsst.afw.geom.makeSkyWcs(crpix, crval1, cd) 

75 wcs2 = lsst.afw.geom.makeSkyWcs(crpix, crval2, cd) 

76 return wcs1, wcs2 

77 

78 

79class JointcalTestBase: 

80 def setUp(self): 

81 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(100, 100) 

82 self.ccdImageList = struct.ccdImageList 

83 # so that countStars() returns nonzero results 

84 for ccdImage in self.ccdImageList: 

85 ccdImage.resetCatalogForFit() 

86 

87 self.goodChi2 = lsst.jointcal.chi2.Chi2Statistic() 

88 # chi2/ndof == 2.0 should be non-bad 

89 self.goodChi2.chi2 = 200.0 

90 self.goodChi2.ndof = 100 

91 

92 self.badChi2 = lsst.jointcal.chi2.Chi2Statistic() 

93 self.badChi2.chi2 = 600.0 

94 self.badChi2.ndof = 100 

95 

96 self.nanChi2 = lsst.jointcal.chi2.Chi2Statistic() 

97 self.nanChi2.chi2 = np.nan 

98 self.nanChi2.ndof = 100 

99 

100 self.maxSteps = 20 

101 self.name = "testing" 

102 self.dataName = "fake" 

103 self.whatToFit = "" # unneeded, since we're mocking the fitter 

104 

105 # Mock a Butler so the refObjLoaders have something to call `get()` on. 

106 self.butler = unittest.mock.Mock(spec=lsst.daf.persistence.Butler) 

107 self.butler.get.return_value.indexer = DatasetConfig().indexer 

108 

109 # Mock the association manager and give it access to the ccd list above. 

110 self.associations = mock.Mock(spec=lsst.jointcal.Associations) 

111 self.associations.getCcdImageList.return_value = self.ccdImageList 

112 

113 # a default config to be modified by individual tests 

114 self.config = lsst.jointcal.jointcal.JointcalConfig() 

115 

116 

117class TestJointcalIterateFit(JointcalTestBase, lsst.utils.tests.TestCase): 

118 def setUp(self): 

119 super().setUp() 

120 # Mock the fitter and model, so we can force particular 

121 # return values/exceptions. Default to "good" return values. 

122 self.fitter = mock.Mock(spec=lsst.jointcal.PhotometryFit) 

123 self.fitter.computeChi2.return_value = self.goodChi2 

124 self.fitter.minimize.return_value = MinimizeResult.Converged 

125 self.model = mock.Mock(spec=lsst.jointcal.SimpleFluxModel) 

126 

127 self.jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler) 

128 

129 def test_iterateFit_success(self): 

130 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter, 

131 self.maxSteps, self.name, self.whatToFit) 

132 self.assertEqual(chi2, self.goodChi2) 

133 # Once for the for loop, the second time for the rank update. 

134 self.assertEqual(self.fitter.minimize.call_count, 2) 

135 

136 def test_iterateFit_writeChi2Outer(self): 

137 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter, 

138 self.maxSteps, self.name, self.whatToFit, 

139 dataName=self.dataName) 

140 self.assertEqual(chi2, self.goodChi2) 

141 # Once for the for loop, the second time for the rank update. 

142 self.assertEqual(self.fitter.minimize.call_count, 2) 

143 # Default config should not call saveChi2Contributions 

144 self.fitter.saveChi2Contributions.assert_not_called() 

145 

146 def test_iterateFit_failed(self): 

147 self.fitter.minimize.return_value = MinimizeResult.Failed 

148 

149 with self.assertRaises(RuntimeError): 

150 self.jointcal._iterate_fit(self.associations, self.fitter, 

151 self.maxSteps, self.name, self.whatToFit) 

152 self.assertEqual(self.fitter.minimize.call_count, 1) 

153 

154 def test_iterateFit_badFinalChi2(self): 

155 log = mock.Mock(spec=lsst.log.Log) 

156 self.jointcal.log = log 

157 self.fitter.computeChi2.return_value = self.badChi2 

158 

159 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter, 

160 self.maxSteps, self.name, self.whatToFit) 

161 self.assertEqual(chi2, self.badChi2) 

162 log.info.assert_called_with("%s %s", "Fit completed", self.badChi2) 

163 log.error.assert_called_with("Potentially bad fit: High chi-squared/ndof.") 

164 

165 def test_iterateFit_exceedMaxSteps(self): 

166 log = mock.Mock(spec=lsst.log.Log) 

167 self.jointcal.log = log 

168 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased 

169 maxSteps = 3 

170 

171 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter, 

172 maxSteps, self.name, self.whatToFit) 

173 self.assertEqual(chi2, self.goodChi2) 

174 self.assertEqual(self.fitter.minimize.call_count, maxSteps) 

175 log.error.assert_called_with("testing failed to converge after %s steps" % maxSteps) 

176 

177 def test_moderate_chi2_increase(self): 

178 """DM-25159: warn, but don't fail, on moderate chi2 increases between 

179 steps. 

180 """ 

181 chi2_1 = lsst.jointcal.chi2.Chi2Statistic() 

182 chi2_1.chi2 = 100.0 

183 chi2_1.ndof = 100 

184 chi2_2 = lsst.jointcal.chi2.Chi2Statistic() 

185 chi2_2.chi2 = 300.0 

186 chi2_2.ndof = 100 

187 

188 chi2s = [self.goodChi2, chi2_1, chi2_2, self.goodChi2, self.goodChi2] 

189 self.fitter.computeChi2.side_effect = chi2s 

190 self.fitter.minimize.side_effect = [MinimizeResult.Chi2Increased, 

191 MinimizeResult.Chi2Increased, 

192 MinimizeResult.Chi2Increased, 

193 MinimizeResult.Converged, 

194 MinimizeResult.Converged] 

195 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log 

196 with self.assertLogs("jointcal", level="WARN") as logger: 

197 self.jointcal._iterate_fit(self.associations, self.fitter, 

198 self.maxSteps, self.name, self.whatToFit) 

199 msg = "WARNING:jointcal:Significant chi2 increase by a factor of 300 / 100 = 3" 

200 self.assertIn(msg, logger.output) 

201 

202 def test_large_chi2_increase_fails(self): 

203 """DM-25159: fail on large chi2 increases between steps.""" 

204 chi2_1 = lsst.jointcal.chi2.Chi2Statistic() 

205 chi2_1.chi2 = 1e11 

206 chi2_1.ndof = 100 

207 chi2_2 = lsst.jointcal.chi2.Chi2Statistic() 

208 chi2_2.chi2 = 1.123456e13 # to check floating point formatting 

209 chi2_2.ndof = 100 

210 

211 chi2s = [chi2_1, chi2_1, chi2_2] 

212 self.fitter.computeChi2.side_effect = chi2s 

213 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased 

214 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log 

215 with self.assertLogs("jointcal", level="WARN") as logger: 

216 with(self.assertRaisesRegex(RuntimeError, "Large chi2 increase")): 

217 self.jointcal._iterate_fit(self.associations, self.fitter, 

218 self.maxSteps, self.name, self.whatToFit) 

219 msg = "WARNING:jointcal:Significant chi2 increase by a factor of 1.123e+13 / 1e+11 = 112.3" 

220 self.assertIn(msg, logger.output) 

221 

222 def test_invalid_model(self): 

223 self.model.validate.return_value = False 

224 with(self.assertRaises(ValueError)): 

225 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "invalid") 

226 

227 def test_nonfinite_chi2(self): 

228 self.fitter.computeChi2.return_value = self.nanChi2 

229 with(self.assertRaises(FloatingPointError)): 

230 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "nonfinite") 

231 

232 def test_writeChi2(self): 

233 filename = "somefile" 

234 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "writeCh2", 

235 writeChi2Name=filename) 

236 # logChi2AndValidate prepends `config.debugOutputPath` to the filename 

237 self.fitter.saveChi2Contributions.assert_called_with("./"+filename+"{type}") 

238 

239 

240class TestJointcalLoadRefCat(JointcalTestBase, lsst.utils.tests.TestCase): 

241 

242 def _make_fake_refcat(self): 

243 """Mock a fake reference catalog and the bits necessary to use it.""" 

244 center = lsst.geom.SpherePoint(30, -30, lsst.geom.degrees) 

245 flux = 10 

246 radius = 1 * lsst.geom.degrees 

247 filterName = 'fake' 

248 

249 fakeRefCat = make_fake_refcat(center, flux, filterName) 

250 fluxField = getRefFluxField(fakeRefCat.schema, filterName) 

251 returnStruct = lsst.pipe.base.Struct(refCat=fakeRefCat, fluxField=fluxField) 

252 refObjLoader = mock.Mock(spec=LoadIndexedReferenceObjectsTask) 

253 refObjLoader.loadSkyCircle.return_value = returnStruct 

254 

255 return refObjLoader, center, radius, filterName, fakeRefCat 

256 

257 def test_load_reference_catalog(self): 

258 refObjLoader, center, radius, filterName, fakeRefCat = self._make_fake_refcat() 

259 

260 config = lsst.jointcal.jointcal.JointcalConfig() 

261 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors 

262 jointcal = lsst.jointcal.JointcalTask(config=config, butler=self.butler) 

263 

264 # NOTE: we cannot test application of proper motion here, because we 

265 # mock the refObjLoader, so the real loader is never called. 

266 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader, 

267 jointcal.astrometryReferenceSelector, 

268 center, 

269 radius, 

270 filterName) 

271 # operator== isn't implemented for Catalogs, so we have to check like 

272 # this, in case the records are copied during load. 

273 self.assertEqual(len(refCat), len(fakeRefCat)) 

274 for r1, r2 in zip(refCat, fakeRefCat): 

275 self.assertEqual(r1, r2) 

276 

277 def test_load_reference_catalog_subselect(self): 

278 """Test that we can select out the one source in the fake refcat 

279 with a ridiculous S/N cut. 

280 """ 

281 refObjLoader, center, radius, filterName, fakeRefCat = self._make_fake_refcat() 

282 

283 config = lsst.jointcal.jointcal.JointcalConfig() 

284 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors 

285 config.astrometryReferenceSelector.doSignalToNoise = True 

286 config.astrometryReferenceSelector.signalToNoise.minimum = 1e10 

287 config.astrometryReferenceSelector.signalToNoise.fluxField = "fake_flux" 

288 config.astrometryReferenceSelector.signalToNoise.errField = "fake_fluxErr" 

289 jointcal = lsst.jointcal.JointcalTask(config=config, butler=self.butler) 

290 

291 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader, 

292 jointcal.astrometryReferenceSelector, 

293 center, 

294 radius, 

295 filterName) 

296 self.assertEqual(len(refCat), 0) 

297 

298 

299class TestJointcalFitModel(JointcalTestBase, lsst.utils.tests.TestCase): 

300 def test_fit_photometry_writeChi2(self): 

301 """Test that we are calling saveChi2 with appropriate file prefixes.""" 

302 self.config.photometryModel = "constrainedFlux" 

303 self.config.writeChi2FilesOuterLoop = True 

304 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler) 

305 jointcal.focalPlaneBBox = lsst.geom.Box2D() 

306 

307 # Mock the fitter, so we can pretend it found a good fit 

308 with mock.patch("lsst.jointcal.PhotometryFit", autospect=True) as fitPatch: 

309 fitPatch.return_value.computeChi2.return_value = self.goodChi2 

310 fitPatch.return_value.minimize.return_value = MinimizeResult.Converged 

311 

312 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions 

313 expected = ["./photometry_init-ModelVisit_chi2", "./photometry_init-Model_chi2", 

314 "./photometry_init-Fluxes_chi2", "./photometry_init-ModelFluxes_chi2"] 

315 expected = [mock.call(x+"-fake{type}") for x in expected] 

316 jointcal._fit_photometry(self.associations, dataName=self.dataName) 

317 fitPatch.return_value.saveChi2Contributions.assert_has_calls(expected) 

318 

319 def test_fit_astrometry_writeChi2(self): 

320 """Test that we are calling saveChi2 with appropriate file prefixes.""" 

321 self.config.astrometryModel = "constrained" 

322 self.config.writeChi2FilesOuterLoop = True 

323 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler) 

324 jointcal.focalPlaneBBox = lsst.geom.Box2D() 

325 

326 # Mock the fitter, so we can pretend it found a good fit 

327 fitPatch = mock.patch("lsst.jointcal.AstrometryFit") 

328 # Mock the projection handler so we don't segfault due to not-fully initialized ccdImages 

329 projectorPatch = mock.patch("lsst.jointcal.OneTPPerVisitHandler") 

330 with fitPatch as fit, projectorPatch as projector: 

331 fit.return_value.computeChi2.return_value = self.goodChi2 

332 fit.return_value.minimize.return_value = MinimizeResult.Converged 

333 # return a real ProjectionHandler to keep ConstrainedAstrometryModel() happy 

334 projector.return_value = lsst.jointcal.IdentityProjectionHandler() 

335 

336 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions 

337 expected = ["./astrometry_init-DistortionsVisit_chi2", "./astrometry_init-Distortions_chi2", 

338 "./astrometry_init-Positions_chi2", "./astrometry_init-DistortionsPositions_chi2"] 

339 expected = [mock.call(x+"-fake{type}") for x in expected] 

340 jointcal._fit_astrometry(self.associations, dataName=self.dataName) 

341 fit.return_value.saveChi2Contributions.assert_has_calls(expected) 

342 

343 

344class TestComputeBoundingCircle(lsst.utils.tests.TestCase): 

345 """Tests of Associations.computeBoundingCircle()""" 

346 def _checkPointsInCircle(self, points, center, radius): 

347 """Check that all points are within the (center, radius) circle. 

348 

349 The test is whether the max(points - center) separation is equal to 

350 (or slightly less than) radius. 

351 """ 

352 maxSeparation = 0*lsst.geom.degrees 

353 for point in points: 

354 maxSeparation = max(maxSeparation, center.separation(point)) 

355 self.assertAnglesAlmostEqual(maxSeparation, radius, maxDiff=3*lsst.geom.arcseconds) 

356 self.assertLess(maxSeparation, radius) 

357 

358 def _testPoints(self, ccdImage1, ccdImage2, skyWcs1, skyWcs2, bbox): 

359 """Fill an Associations object and test that it computes the correct 

360 bounding circle for the input data. 

361 

362 Parameters 

363 ---------- 

364 ccdImage1, ccdImage2 : `lsst.jointcal.CcdImage` 

365 The CcdImages to add to the Associations object. 

366 skyWcs1, skyWcs2 : `lsst.afw.geom.SkyWcs` 

367 The WCS of each of the above images. 

368 bbox : `lsst.geom.Box2D` 

369 The ccd bounding box of both images. 

370 """ 

371 lsst.log.setLevel('jointcal', lsst.log.DEBUG) 

372 associations = lsst.jointcal.Associations() 

373 associations.addCcdImage(ccdImage1) 

374 associations.addCcdImage(ccdImage2) 

375 associations.computeCommonTangentPoint() 

376 

377 circle = associations.computeBoundingCircle() 

378 center = lsst.geom.SpherePoint(circle.getCenter()) 

379 radius = lsst.geom.Angle(circle.getOpeningAngle().asRadians(), lsst.geom.radians) 

380 points = [lsst.geom.SpherePoint(skyWcs1.pixelToSky(lsst.geom.Point2D(x))) 

381 for x in bbox.getCorners()] 

382 points.extend([lsst.geom.SpherePoint(skyWcs2.pixelToSky(lsst.geom.Point2D(x))) 

383 for x in bbox.getCorners()]) 

384 self._checkPointsInCircle(points, center, radius) 

385 

386 def testPoints(self): 

387 """Test for points in an "easy" area, far from RA=0 or the poles.""" 

388 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages() 

389 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1], 

390 struct.skyWcs[0], struct.skyWcs[1], struct.bbox) 

391 

392 def testPointsRA0(self): 

393 """Test for CcdImages crossing RA=0; this demonstrates a fix for 

394 the bug described in DM-19802. 

395 """ 

396 wcs1, wcs2 = make_fake_wcs() 

397 

398 # Put the visit boresights at the WCS origin, for consistency 

399 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512, 

400 date=DateTime(65321.1), 

401 boresightRaDec=wcs1.getSkyOrigin()) 

402 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144, 

403 date=DateTime(65322.1), 

404 boresightRaDec=wcs1.getSkyOrigin()) 

405 

406 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2], 

407 fakeVisitInfos=[visitInfo1, visitInfo2]) 

408 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1], 

409 struct.skyWcs[0], struct.skyWcs[1], struct.bbox) 

410 

411 

412class TestJointcalComputePMDate(JointcalTestBase, lsst.utils.tests.TestCase): 

413 """Tests of jointcal._compute_proper_motion_epoch()""" 

414 def test_compute_proper_motion_epoch(self): 

415 mjds = np.array((65432.1, 66666, 65555, 64322.2)) 

416 

417 wcs1, wcs2 = make_fake_wcs() 

418 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512, 

419 date=DateTime(mjds[0]), 

420 boresightRaDec=wcs1.getSkyOrigin()) 

421 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144, 

422 date=DateTime(mjds[1]), 

423 boresightRaDec=wcs2.getSkyOrigin()) 

424 visitInfo3 = lsst.afw.image.VisitInfo(exposureId=30577513, 

425 date=DateTime(mjds[2]), 

426 boresightRaDec=wcs1.getSkyOrigin()) 

427 visitInfo4 = lsst.afw.image.VisitInfo(exposureId=30621145, 

428 date=DateTime(mjds[3]), 

429 boresightRaDec=wcs2.getSkyOrigin()) 

430 

431 struct1 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2], 

432 fakeVisitInfos=[visitInfo1, visitInfo2]) 

433 struct2 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2], 

434 fakeVisitInfos=[visitInfo3, visitInfo4]) 

435 ccdImageList = list(itertools.chain(struct1.ccdImageList, struct2.ccdImageList)) 

436 associations = lsst.jointcal.Associations() 

437 for ccdImage in ccdImageList: 

438 associations.addCcdImage(ccdImage) 

439 associations.computeCommonTangentPoint() 

440 

441 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler) 

442 result = jointcal._compute_proper_motion_epoch(ccdImageList) 

443 self.assertEqual(result.mjd, mjds.mean()) 

444 

445 

446class MemoryTester(lsst.utils.tests.MemoryTestCase): 

447 pass 

448 

449 

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

451 lsst.utils.tests.init() 

452 unittest.main()