Coverage for tests/test_jointcal.py : 19%

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/>.
22import itertools
23import os.path
24import unittest
25from unittest import mock
27import numpy as np
28import pyarrow.parquet
30import lsst.log
31import lsst.utils
33import lsst.afw.table
34import lsst.daf.persistence
35from lsst.daf.base import DateTime
36import lsst.geom
37from lsst.meas.algorithms import getRefFluxField, LoadIndexedReferenceObjectsTask, DatasetConfig
38import lsst.pipe.base
39import lsst.jointcal
40from lsst.jointcal import MinimizeResult
41import lsst.jointcal.chi2
42import lsst.jointcal.testUtils
45# for MemoryTestCase
46def setup_module(module):
47 lsst.utils.tests.init()
50def make_fake_refcat(center, flux, filterName):
51 """Make a fake reference catalog."""
52 schema = LoadIndexedReferenceObjectsTask.makeMinimalSchema([filterName],
53 addProperMotion=True)
54 catalog = lsst.afw.table.SimpleCatalog(schema)
55 record = catalog.addNew()
56 record.setCoord(center)
57 record[filterName + '_flux'] = flux
58 record[filterName + '_fluxErr'] = flux*0.1
59 record['pm_ra'] = lsst.geom.Angle(1)
60 record['pm_dec'] = lsst.geom.Angle(2)
61 record['epoch'] = 65432.1
62 return catalog
65def make_fake_wcs():
66 """Return two simple SkyWcs objects, with slightly different sky positions.
68 Use the same pixel origins as the cfht_minimal data, but put the sky origin
69 at RA=0
70 """
71 crpix = lsst.geom.Point2D(931.517869, 2438.572109)
72 cd = np.array([[5.19513851e-05, -2.81124812e-07],
73 [-3.25186974e-07, -5.19112119e-05]])
74 crval1 = lsst.geom.SpherePoint(0.01, -0.01, lsst.geom.degrees)
75 crval2 = lsst.geom.SpherePoint(-0.01, 0.01, lsst.geom.degrees)
76 wcs1 = lsst.afw.geom.makeSkyWcs(crpix, crval1, cd)
77 wcs2 = lsst.afw.geom.makeSkyWcs(crpix, crval2, cd)
78 return wcs1, wcs2
81class TestJointcalVisitCatalog(lsst.utils.tests.TestCase):
82 """Tests of jointcal's sourceTable_visit parquet ->single detector afw
83 table catalog unrolling.
84 """
85 def setUp(self):
86 filename = os.path.join(os.path.dirname(__file__),
87 "data/subselected-sourceTable-0034690.parq")
88 file = pyarrow.parquet.ParquetFile(filename)
89 self.data = file.read(use_pandas_metadata=True).to_pandas()
90 config = lsst.jointcal.jointcal.JointcalConfig()
91 # TODO DM-29008: Remove this (to use the new gen3 default) before gen2 removal.
92 config.sourceFluxType = "ApFlux_12_0"
93 # we don't actually need either fitter to run for these tests
94 config.doAstrometry = False
95 config.doPhotometry = False
96 self.jointcal = lsst.jointcal.JointcalTask(config=config)
98 def test_make_catalog_schema(self):
99 """Check that the slot fields required by CcdImage::loadCatalog are in
100 the schema returned by _make_catalog_schema().
101 """
102 table = self.jointcal._make_schema_table()
103 self.assertTrue(table.getCentroidSlot().getMeasKey().isValid())
104 self.assertTrue(table.getCentroidSlot().getErrKey().isValid())
105 self.assertTrue(table.getShapeSlot().getMeasKey().isValid())
107 def test_extract_detector_catalog_from_visit_catalog(self):
108 """Spot check a value output by the script that generated the test
109 parquet catalog and check that the size of the returned catalog
110 is correct for each detectior.
111 """
112 detectorId = 56
113 table = self.jointcal._make_schema_table()
114 catalog = self.jointcal._extract_detector_catalog_from_visit_catalog(table, self.data, detectorId)
116 # The test catalog has a number of elements for each detector equal to the detector id.
117 self.assertEqual(len(catalog), detectorId)
118 self.assertIn(29798723617816629, catalog['id'])
119 matched = catalog[29798723617816629 == catalog['id']]
120 self.assertEqual(1715.734359473175, matched['slot_Centroid_x'])
121 self.assertEqual(89.06076509964362, matched['slot_Centroid_y'])
124class JointcalTestBase:
125 def setUp(self):
126 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(100, 100)
127 self.ccdImageList = struct.ccdImageList
128 # so that countStars() returns nonzero results
129 for ccdImage in self.ccdImageList:
130 ccdImage.resetCatalogForFit()
132 self.goodChi2 = lsst.jointcal.chi2.Chi2Statistic()
133 # chi2/ndof == 2.0 should be non-bad
134 self.goodChi2.chi2 = 200.0
135 self.goodChi2.ndof = 100
137 self.badChi2 = lsst.jointcal.chi2.Chi2Statistic()
138 self.badChi2.chi2 = 600.0
139 self.badChi2.ndof = 100
141 self.nanChi2 = lsst.jointcal.chi2.Chi2Statistic()
142 self.nanChi2.chi2 = np.nan
143 self.nanChi2.ndof = 100
145 self.maxSteps = 20
146 self.name = "testing"
147 self.dataName = "fake"
148 self.whatToFit = "" # unneeded, since we're mocking the fitter
150 # Mock a Butler so the refObjLoaders have something to call `get()` on.
151 self.butler = unittest.mock.Mock(spec=lsst.daf.persistence.Butler)
152 self.butler.get.return_value.indexer = DatasetConfig().indexer
154 # Mock the association manager and give it access to the ccd list above.
155 self.associations = mock.Mock(spec=lsst.jointcal.Associations)
156 self.associations.getCcdImageList.return_value = self.ccdImageList
158 # a default config to be modified by individual tests
159 self.config = lsst.jointcal.jointcal.JointcalConfig()
162class TestJointcalIterateFit(JointcalTestBase, lsst.utils.tests.TestCase):
163 def setUp(self):
164 super().setUp()
165 # Mock the fitter and model, so we can force particular
166 # return values/exceptions. Default to "good" return values.
167 self.fitter = mock.Mock(spec=lsst.jointcal.PhotometryFit)
168 self.fitter.computeChi2.return_value = self.goodChi2
169 self.fitter.minimize.return_value = MinimizeResult.Converged
170 self.model = mock.Mock(spec=lsst.jointcal.SimpleFluxModel)
172 self.jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler)
174 def test_iterateFit_success(self):
175 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
176 self.maxSteps, self.name, self.whatToFit)
177 self.assertEqual(chi2, self.goodChi2)
178 # Once for the for loop, the second time for the rank update.
179 self.assertEqual(self.fitter.minimize.call_count, 2)
181 def test_iterateFit_writeChi2Outer(self):
182 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
183 self.maxSteps, self.name, self.whatToFit,
184 dataName=self.dataName)
185 self.assertEqual(chi2, self.goodChi2)
186 # Once for the for loop, the second time for the rank update.
187 self.assertEqual(self.fitter.minimize.call_count, 2)
188 # Default config should not call saveChi2Contributions
189 self.fitter.saveChi2Contributions.assert_not_called()
191 def test_iterateFit_failed(self):
192 self.fitter.minimize.return_value = MinimizeResult.Failed
194 with self.assertRaises(RuntimeError):
195 self.jointcal._iterate_fit(self.associations, self.fitter,
196 self.maxSteps, self.name, self.whatToFit)
197 self.assertEqual(self.fitter.minimize.call_count, 1)
199 def test_iterateFit_badFinalChi2(self):
200 log = mock.Mock(spec=lsst.log.Log)
201 self.jointcal.log = log
202 self.fitter.computeChi2.return_value = self.badChi2
204 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
205 self.maxSteps, self.name, self.whatToFit)
206 self.assertEqual(chi2, self.badChi2)
207 log.info.assert_called_with("%s %s", "Fit completed", self.badChi2)
208 log.error.assert_called_with("Potentially bad fit: High chi-squared/ndof.")
210 def test_iterateFit_exceedMaxSteps(self):
211 log = mock.Mock(spec=lsst.log.Log)
212 self.jointcal.log = log
213 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased
214 maxSteps = 3
216 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
217 maxSteps, self.name, self.whatToFit)
218 self.assertEqual(chi2, self.goodChi2)
219 self.assertEqual(self.fitter.minimize.call_count, maxSteps)
220 log.error.assert_called_with("testing failed to converge after %s steps" % maxSteps)
222 def test_moderate_chi2_increase(self):
223 """DM-25159: warn, but don't fail, on moderate chi2 increases between
224 steps.
225 """
226 chi2_1 = lsst.jointcal.chi2.Chi2Statistic()
227 chi2_1.chi2 = 100.0
228 chi2_1.ndof = 100
229 chi2_2 = lsst.jointcal.chi2.Chi2Statistic()
230 chi2_2.chi2 = 300.0
231 chi2_2.ndof = 100
233 chi2s = [self.goodChi2, chi2_1, chi2_2, self.goodChi2, self.goodChi2]
234 self.fitter.computeChi2.side_effect = chi2s
235 self.fitter.minimize.side_effect = [MinimizeResult.Chi2Increased,
236 MinimizeResult.Chi2Increased,
237 MinimizeResult.Chi2Increased,
238 MinimizeResult.Converged,
239 MinimizeResult.Converged]
240 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log
241 with self.assertLogs("jointcal", level="WARN") as logger:
242 self.jointcal._iterate_fit(self.associations, self.fitter,
243 self.maxSteps, self.name, self.whatToFit)
244 msg = "WARNING:jointcal:Significant chi2 increase by a factor of 300 / 100 = 3"
245 self.assertIn(msg, logger.output)
247 def test_large_chi2_increase_fails(self):
248 """DM-25159: fail on large chi2 increases between steps."""
249 chi2_1 = lsst.jointcal.chi2.Chi2Statistic()
250 chi2_1.chi2 = 1e11
251 chi2_1.ndof = 100
252 chi2_2 = lsst.jointcal.chi2.Chi2Statistic()
253 chi2_2.chi2 = 1.123456e13 # to check floating point formatting
254 chi2_2.ndof = 100
256 chi2s = [chi2_1, chi2_1, chi2_2]
257 self.fitter.computeChi2.side_effect = chi2s
258 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased
259 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log
260 with self.assertLogs("jointcal", level="WARN") as logger:
261 with(self.assertRaisesRegex(RuntimeError, "Large chi2 increase")):
262 self.jointcal._iterate_fit(self.associations, self.fitter,
263 self.maxSteps, self.name, self.whatToFit)
264 msg = "WARNING:jointcal:Significant chi2 increase by a factor of 1.123e+13 / 1e+11 = 112.3"
265 self.assertIn(msg, logger.output)
267 def test_invalid_model(self):
268 self.model.validate.return_value = False
269 with(self.assertRaises(ValueError)):
270 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "invalid")
272 def test_nonfinite_chi2(self):
273 self.fitter.computeChi2.return_value = self.nanChi2
274 with(self.assertRaises(FloatingPointError)):
275 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "nonfinite")
277 def test_writeChi2(self):
278 filename = "somefile"
279 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "writeCh2",
280 writeChi2Name=filename)
281 # logChi2AndValidate prepends `config.debugOutputPath` to the filename
282 self.fitter.saveChi2Contributions.assert_called_with("./"+filename+"{type}")
285class TestJointcalLoadRefCat(JointcalTestBase, lsst.utils.tests.TestCase):
287 def _make_fake_refcat(self):
288 """Mock a fake reference catalog and the bits necessary to use it."""
289 center = lsst.geom.SpherePoint(30, -30, lsst.geom.degrees)
290 flux = 10
291 radius = 1 * lsst.geom.degrees
292 filterName = 'fake'
294 fakeRefCat = make_fake_refcat(center, flux, filterName)
295 fluxField = getRefFluxField(fakeRefCat.schema, filterName)
296 returnStruct = lsst.pipe.base.Struct(refCat=fakeRefCat, fluxField=fluxField)
297 refObjLoader = mock.Mock(spec=LoadIndexedReferenceObjectsTask)
298 refObjLoader.loadSkyCircle.return_value = returnStruct
300 return refObjLoader, center, radius, filterName, fakeRefCat
302 def test_load_reference_catalog(self):
303 refObjLoader, center, radius, filterName, fakeRefCat = self._make_fake_refcat()
305 config = lsst.jointcal.jointcal.JointcalConfig()
306 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors
307 jointcal = lsst.jointcal.JointcalTask(config=config, butler=self.butler)
309 # NOTE: we cannot test application of proper motion here, because we
310 # mock the refObjLoader, so the real loader is never called.
311 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader,
312 jointcal.astrometryReferenceSelector,
313 center,
314 radius,
315 filterName)
316 # operator== isn't implemented for Catalogs, so we have to check like
317 # this, in case the records are copied during load.
318 self.assertEqual(len(refCat), len(fakeRefCat))
319 for r1, r2 in zip(refCat, fakeRefCat):
320 self.assertEqual(r1, r2)
322 def test_load_reference_catalog_subselect(self):
323 """Test that we can select out the one source in the fake refcat
324 with a ridiculous S/N cut.
325 """
326 refObjLoader, center, radius, filterName, fakeRefCat = self._make_fake_refcat()
328 config = lsst.jointcal.jointcal.JointcalConfig()
329 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors
330 config.astrometryReferenceSelector.doSignalToNoise = True
331 config.astrometryReferenceSelector.signalToNoise.minimum = 1e10
332 config.astrometryReferenceSelector.signalToNoise.fluxField = "fake_flux"
333 config.astrometryReferenceSelector.signalToNoise.errField = "fake_fluxErr"
334 jointcal = lsst.jointcal.JointcalTask(config=config, butler=self.butler)
336 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader,
337 jointcal.astrometryReferenceSelector,
338 center,
339 radius,
340 filterName)
341 self.assertEqual(len(refCat), 0)
344class TestJointcalFitModel(JointcalTestBase, lsst.utils.tests.TestCase):
345 def test_fit_photometry_writeChi2(self):
346 """Test that we are calling saveChi2 with appropriate file prefixes."""
347 self.config.photometryModel = "constrainedFlux"
348 self.config.writeChi2FilesOuterLoop = True
349 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler)
350 jointcal.focalPlaneBBox = lsst.geom.Box2D()
352 # Mock the fitter, so we can pretend it found a good fit
353 with mock.patch("lsst.jointcal.PhotometryFit", autospect=True) as fitPatch:
354 fitPatch.return_value.computeChi2.return_value = self.goodChi2
355 fitPatch.return_value.minimize.return_value = MinimizeResult.Converged
357 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions
358 expected = ["./photometry_init-ModelVisit_chi2", "./photometry_init-Model_chi2",
359 "./photometry_init-Fluxes_chi2", "./photometry_init-ModelFluxes_chi2"]
360 expected = [mock.call(x+"-fake{type}") for x in expected]
361 jointcal._fit_photometry(self.associations, dataName=self.dataName)
362 fitPatch.return_value.saveChi2Contributions.assert_has_calls(expected)
364 def test_fit_astrometry_writeChi2(self):
365 """Test that we are calling saveChi2 with appropriate file prefixes."""
366 self.config.astrometryModel = "constrained"
367 self.config.writeChi2FilesOuterLoop = True
368 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler)
369 jointcal.focalPlaneBBox = lsst.geom.Box2D()
371 # Mock the fitter, so we can pretend it found a good fit
372 fitPatch = mock.patch("lsst.jointcal.AstrometryFit")
373 # Mock the projection handler so we don't segfault due to not-fully initialized ccdImages
374 projectorPatch = mock.patch("lsst.jointcal.OneTPPerVisitHandler")
375 with fitPatch as fit, projectorPatch as projector:
376 fit.return_value.computeChi2.return_value = self.goodChi2
377 fit.return_value.minimize.return_value = MinimizeResult.Converged
378 # return a real ProjectionHandler to keep ConstrainedAstrometryModel() happy
379 projector.return_value = lsst.jointcal.IdentityProjectionHandler()
381 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions
382 expected = ["./astrometry_init-DistortionsVisit_chi2", "./astrometry_init-Distortions_chi2",
383 "./astrometry_init-Positions_chi2", "./astrometry_init-DistortionsPositions_chi2"]
384 expected = [mock.call(x+"-fake{type}") for x in expected]
385 jointcal._fit_astrometry(self.associations, dataName=self.dataName)
386 fit.return_value.saveChi2Contributions.assert_has_calls(expected)
389class TestComputeBoundingCircle(lsst.utils.tests.TestCase):
390 """Tests of Associations.computeBoundingCircle()"""
391 def _checkPointsInCircle(self, points, center, radius):
392 """Check that all points are within the (center, radius) circle.
394 The test is whether the max(points - center) separation is equal to
395 (or slightly less than) radius.
396 """
397 maxSeparation = 0*lsst.geom.degrees
398 for point in points:
399 maxSeparation = max(maxSeparation, center.separation(point))
400 self.assertAnglesAlmostEqual(maxSeparation, radius, maxDiff=3*lsst.geom.arcseconds)
401 self.assertLess(maxSeparation, radius)
403 def _testPoints(self, ccdImage1, ccdImage2, skyWcs1, skyWcs2, bbox):
404 """Fill an Associations object and test that it computes the correct
405 bounding circle for the input data.
407 Parameters
408 ----------
409 ccdImage1, ccdImage2 : `lsst.jointcal.CcdImage`
410 The CcdImages to add to the Associations object.
411 skyWcs1, skyWcs2 : `lsst.afw.geom.SkyWcs`
412 The WCS of each of the above images.
413 bbox : `lsst.geom.Box2D`
414 The ccd bounding box of both images.
415 """
416 lsst.log.setLevel('jointcal', lsst.log.DEBUG)
417 associations = lsst.jointcal.Associations()
418 associations.addCcdImage(ccdImage1)
419 associations.addCcdImage(ccdImage2)
420 associations.computeCommonTangentPoint()
422 circle = associations.computeBoundingCircle()
423 center = lsst.geom.SpherePoint(circle.getCenter())
424 radius = lsst.geom.Angle(circle.getOpeningAngle().asRadians(), lsst.geom.radians)
425 points = [lsst.geom.SpherePoint(skyWcs1.pixelToSky(lsst.geom.Point2D(x)))
426 for x in bbox.getCorners()]
427 points.extend([lsst.geom.SpherePoint(skyWcs2.pixelToSky(lsst.geom.Point2D(x)))
428 for x in bbox.getCorners()])
429 self._checkPointsInCircle(points, center, radius)
431 def testPoints(self):
432 """Test for points in an "easy" area, far from RA=0 or the poles."""
433 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages()
434 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1],
435 struct.skyWcs[0], struct.skyWcs[1], struct.bbox)
437 def testPointsRA0(self):
438 """Test for CcdImages crossing RA=0; this demonstrates a fix for
439 the bug described in DM-19802.
440 """
441 wcs1, wcs2 = make_fake_wcs()
443 # Put the visit boresights at the WCS origin, for consistency
444 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512,
445 date=DateTime(65321.1),
446 boresightRaDec=wcs1.getSkyOrigin())
447 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144,
448 date=DateTime(65322.1),
449 boresightRaDec=wcs1.getSkyOrigin())
451 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
452 fakeVisitInfos=[visitInfo1, visitInfo2])
453 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1],
454 struct.skyWcs[0], struct.skyWcs[1], struct.bbox)
457class TestJointcalComputePMDate(JointcalTestBase, lsst.utils.tests.TestCase):
458 """Tests of jointcal._compute_proper_motion_epoch()"""
459 def test_compute_proper_motion_epoch(self):
460 mjds = np.array((65432.1, 66666, 65555, 64322.2))
462 wcs1, wcs2 = make_fake_wcs()
463 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512,
464 date=DateTime(mjds[0]),
465 boresightRaDec=wcs1.getSkyOrigin())
466 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144,
467 date=DateTime(mjds[1]),
468 boresightRaDec=wcs2.getSkyOrigin())
469 visitInfo3 = lsst.afw.image.VisitInfo(exposureId=30577513,
470 date=DateTime(mjds[2]),
471 boresightRaDec=wcs1.getSkyOrigin())
472 visitInfo4 = lsst.afw.image.VisitInfo(exposureId=30621145,
473 date=DateTime(mjds[3]),
474 boresightRaDec=wcs2.getSkyOrigin())
476 struct1 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
477 fakeVisitInfos=[visitInfo1, visitInfo2])
478 struct2 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
479 fakeVisitInfos=[visitInfo3, visitInfo4])
480 ccdImageList = list(itertools.chain(struct1.ccdImageList, struct2.ccdImageList))
481 associations = lsst.jointcal.Associations()
482 for ccdImage in ccdImageList:
483 associations.addCcdImage(ccdImage)
484 associations.computeCommonTangentPoint()
486 jointcal = lsst.jointcal.JointcalTask(config=self.config, butler=self.butler)
487 result = jointcal._compute_proper_motion_epoch(ccdImageList)
488 self.assertEqual(result.mjd, mjds.mean())
491class MemoryTester(lsst.utils.tests.MemoryTestCase):
492 pass
495if __name__ == "__main__": 495 ↛ 496line 495 didn't jump to line 496, because the condition on line 495 was never true
496 lsst.utils.tests.init()
497 unittest.main()