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