Coverage for tests/test_jointcal.py: 19%
282 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-09 03:27 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-09 03:27 -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
39from lsst.meas.algorithms import convertReferenceCatalog
40import lsst.obs.base
41import lsst.pipe.base
42import lsst.jointcal
43from lsst.jointcal.jointcal import make_schema_table, extract_detector_catalog_from_visit_catalog
44from lsst.jointcal import MinimizeResult
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 = convertReferenceCatalog._makeSchema([filterName], fullPositionInformation=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 ['Ixx', 'Iyy', 'Ixy'],
121 self.config.sourceFluxType,
122 self.jointcal.log)
124 # The test catalog has a number of elements for each detector equal to the detector id.
125 self.assertEqual(len(catalog), detectorId)
126 self.assertIn(29798723617816629, catalog['id'])
127 matched = catalog[29798723617816629 == catalog['id']]
128 self.assertEqual(1715.734359473175, matched['slot_Centroid_x'])
129 self.assertEqual(89.06076509964362, matched['slot_Centroid_y'])
132class JointcalTestBase:
133 def setUp(self):
134 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(100, 100)
135 self.ccdImageList = struct.ccdImageList
136 # so that countStars() returns nonzero results
137 for ccdImage in self.ccdImageList:
138 ccdImage.resetCatalogForFit()
140 self.goodChi2 = lsst.jointcal.Chi2Statistic()
141 # chi2/ndof == 2.0 should be non-bad
142 self.goodChi2.chi2 = 200.0
143 self.goodChi2.ndof = 100
145 self.badChi2 = lsst.jointcal.Chi2Statistic()
146 self.badChi2.chi2 = 600.0
147 self.badChi2.ndof = 100
149 self.nanChi2 = lsst.jointcal.Chi2Statistic()
150 self.nanChi2.chi2 = np.nan
151 self.nanChi2.ndof = 100
153 self.maxSteps = 20
154 self.name = "testing"
155 self.dataName = "fake"
156 self.whatToFit = "" # unneeded, since we're mocking the fitter
158 # Mock a Butler so the refObjLoaders have something to call `get()` on.
159 self.butler = unittest.mock.Mock(spec=lsst.daf.butler.Butler)
161 # Mock the association manager and give it access to the ccd list above.
162 self.associations = mock.Mock(spec=lsst.jointcal.Associations)
163 self.associations.getCcdImageList.return_value = self.ccdImageList
165 # a default config to be modified by individual tests
166 self.config = lsst.jointcal.jointcal.JointcalConfig()
169class TestJointcalIterateFit(JointcalTestBase, lsst.utils.tests.TestCase):
170 def setUp(self):
171 super().setUp()
172 # Mock the fitter and model, so we can force particular
173 # return values/exceptions. Default to "good" return values.
174 self.fitter = mock.Mock(spec=lsst.jointcal.PhotometryFit)
175 self.fitter.computeChi2.return_value = self.goodChi2
176 self.fitter.minimize.return_value = MinimizeResult.Converged
177 self.model = mock.Mock(spec=lsst.jointcal.SimpleFluxModel)
179 self.jointcal = lsst.jointcal.JointcalTask(config=self.config)
181 def test_iterateFit_success(self):
182 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
183 self.maxSteps, self.name, self.whatToFit)
184 self.assertEqual(chi2, self.goodChi2)
185 # Once for the for loop, the second time for the rank update.
186 self.assertEqual(self.fitter.minimize.call_count, 2)
188 def test_iterateFit_writeChi2Outer(self):
189 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
190 self.maxSteps, self.name, self.whatToFit,
191 dataName=self.dataName)
192 self.assertEqual(chi2, self.goodChi2)
193 # Once for the for loop, the second time for the rank update.
194 self.assertEqual(self.fitter.minimize.call_count, 2)
195 # Default config should not call saveChi2Contributions
196 self.fitter.saveChi2Contributions.assert_not_called()
198 def test_iterateFit_failed(self):
199 self.fitter.minimize.return_value = MinimizeResult.Failed
201 with self.assertRaises(RuntimeError):
202 self.jointcal._iterate_fit(self.associations, self.fitter,
203 self.maxSteps, self.name, self.whatToFit)
204 self.assertEqual(self.fitter.minimize.call_count, 1)
206 def test_iterateFit_badFinalChi2(self):
207 log = mock.Mock(spec=lsst.log.Log)
208 self.jointcal.log = log
209 self.fitter.computeChi2.return_value = self.badChi2
211 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
212 self.maxSteps, self.name, self.whatToFit)
213 self.assertEqual(chi2, self.badChi2)
214 log.info.assert_called_with("%s %s", "Fit completed", self.badChi2)
215 log.error.assert_called_with("Potentially bad fit: High chi-squared/ndof.")
217 def test_iterateFit_exceedMaxSteps(self):
218 log = mock.Mock(spec=lsst.log.Log)
219 self.jointcal.log = log
220 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased
221 maxSteps = 3
223 chi2 = self.jointcal._iterate_fit(self.associations, self.fitter,
224 maxSteps, self.name, self.whatToFit)
225 self.assertEqual(chi2, self.goodChi2)
226 self.assertEqual(self.fitter.minimize.call_count, maxSteps)
227 log.error.assert_called_with("testing failed to converge after %s steps" % maxSteps)
229 def test_moderate_chi2_increase(self):
230 """DM-25159: warn, but don't fail, on moderate chi2 increases between
231 steps.
232 """
233 chi2_1 = lsst.jointcal.Chi2Statistic()
234 chi2_1.chi2 = 100.0
235 chi2_1.ndof = 100
236 chi2_2 = lsst.jointcal.Chi2Statistic()
237 chi2_2.chi2 = 300.0
238 chi2_2.ndof = 100
240 chi2s = [self.goodChi2, chi2_1, chi2_2, self.goodChi2, self.goodChi2]
241 self.fitter.computeChi2.side_effect = chi2s
242 self.fitter.minimize.side_effect = [MinimizeResult.Chi2Increased,
243 MinimizeResult.Chi2Increased,
244 MinimizeResult.Chi2Increased,
245 MinimizeResult.Converged,
246 MinimizeResult.Converged]
247 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log
248 with self.assertLogs("lsst.jointcal", level="WARNING") as logger:
249 self.jointcal._iterate_fit(self.associations, self.fitter,
250 self.maxSteps, self.name, self.whatToFit)
251 msg = "Significant chi2 increase by a factor of 300 / 100 = 3"
252 self.assertIn(msg, [rec.message for rec in logger.records])
254 def test_large_chi2_increase_fails(self):
255 """DM-25159: fail on large chi2 increases between steps."""
256 chi2_1 = lsst.jointcal.Chi2Statistic()
257 chi2_1.chi2 = 1e11
258 chi2_1.ndof = 100
259 chi2_2 = lsst.jointcal.Chi2Statistic()
260 chi2_2.chi2 = 1.123456e13 # to check floating point formatting
261 chi2_2.ndof = 100
263 chi2s = [chi2_1, chi2_1, chi2_2]
264 self.fitter.computeChi2.side_effect = chi2s
265 self.fitter.minimize.return_value = MinimizeResult.Chi2Increased
266 with lsst.log.UsePythonLogging(): # so that assertLogs works with lsst.log
267 with self.assertLogs("lsst.jointcal", level="WARNING") as logger:
268 with(self.assertRaisesRegex(RuntimeError, "Large chi2 increase")):
269 self.jointcal._iterate_fit(self.associations, self.fitter,
270 self.maxSteps, self.name, self.whatToFit)
271 msg = "Significant chi2 increase by a factor of 1.123e+13 / 1e+11 = 112.3"
272 self.assertIn(msg, [rec.message for rec in logger.records])
274 def test_invalid_model(self):
275 self.model.validate.return_value = False
276 with(self.assertRaises(ValueError)):
277 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "invalid")
279 def test_nonfinite_chi2(self):
280 self.fitter.computeChi2.return_value = self.nanChi2
281 with(self.assertRaises(FloatingPointError)):
282 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "nonfinite")
284 def test_writeChi2(self):
285 filename = "somefile"
286 self.jointcal._logChi2AndValidate(self.associations, self.fitter, self.model, "writeCh2",
287 writeChi2Name=filename)
288 # logChi2AndValidate prepends `config.debugOutputPath` to the filename
289 self.fitter.saveChi2Contributions.assert_called_with("./"+filename+"{type}")
292class TestJointcalLoadRefCat(JointcalTestBase, lsst.utils.tests.TestCase):
294 def _make_fake_refcat(self):
295 """Mock a fake reference catalog and the bits necessary to use it."""
296 center = lsst.geom.SpherePoint(30, -30, lsst.geom.degrees)
297 flux = 10
298 radius = 1 * lsst.geom.degrees
299 filter = lsst.afw.image.FilterLabel(band='fake', physical="fake-filter")
301 fakeRefCat = make_fake_refcat(center, flux, filter.bandLabel)
302 fluxField = getRefFluxField(fakeRefCat.schema, filter.bandLabel)
303 returnStruct = lsst.pipe.base.Struct(refCat=fakeRefCat, fluxField=fluxField)
304 refObjLoader = mock.Mock(spec=ReferenceObjectLoader)
305 refObjLoader.loadSkyCircle.return_value = returnStruct
307 return refObjLoader, center, radius, filter, fakeRefCat
309 def test_load_reference_catalog(self):
310 refObjLoader, center, radius, filterLabel, fakeRefCat = self._make_fake_refcat()
312 config = lsst.jointcal.jointcal.JointcalConfig()
313 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors
314 jointcal = lsst.jointcal.JointcalTask(config=config)
316 # NOTE: we cannot test application of proper motion here, because we
317 # mock the refObjLoader, so the real loader is never called.
318 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader,
319 jointcal.astrometryReferenceSelector,
320 center,
321 radius,
322 filterLabel)
323 # operator== isn't implemented for Catalogs, so we have to check like
324 # this, in case the records are copied during load.
325 self.assertEqual(len(refCat), len(fakeRefCat))
326 for r1, r2 in zip(refCat, fakeRefCat):
327 self.assertEqual(r1, r2)
329 def test_load_reference_catalog_subselect(self):
330 """Test that we can select out the one source in the fake refcat
331 with a ridiculous S/N cut.
332 """
333 refObjLoader, center, radius, filterLabel, fakeRefCat = self._make_fake_refcat()
335 config = lsst.jointcal.jointcal.JointcalConfig()
336 config.astrometryReferenceErr = 0.1 # our test refcats don't have coord errors
337 config.astrometryReferenceSelector.doSignalToNoise = True
338 config.astrometryReferenceSelector.signalToNoise.minimum = 1e10
339 config.astrometryReferenceSelector.signalToNoise.fluxField = "fake_flux"
340 config.astrometryReferenceSelector.signalToNoise.errField = "fake_fluxErr"
341 jointcal = lsst.jointcal.JointcalTask(config=config)
343 refCat, fluxField = jointcal._load_reference_catalog(refObjLoader,
344 jointcal.astrometryReferenceSelector,
345 center,
346 radius,
347 filterLabel)
348 self.assertEqual(len(refCat), 0)
351class TestJointcalFitModel(JointcalTestBase, lsst.utils.tests.TestCase):
352 def test_fit_photometry_writeChi2(self):
353 """Test that we are calling saveChi2 with appropriate file prefixes."""
354 self.config.photometryModel = "constrainedFlux"
355 self.config.writeChi2FilesOuterLoop = True
356 jointcal = lsst.jointcal.JointcalTask(config=self.config)
357 jointcal.focalPlaneBBox = lsst.geom.Box2D()
359 # Mock the fitter, so we can pretend it found a good fit
360 with mock.patch("lsst.jointcal.PhotometryFit", autospec=True) as fitPatch:
361 fitPatch.return_value.computeChi2.return_value = self.goodChi2
362 fitPatch.return_value.minimize.return_value = MinimizeResult.Converged
364 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions
365 expected = ["./photometry_init-ModelVisit_chi2", "./photometry_init-Model_chi2",
366 "./photometry_init-Fluxes_chi2", "./photometry_init-ModelFluxes_chi2"]
367 expected = [mock.call(x+"-fake{type}") for x in expected]
368 jointcal._fit_photometry(self.associations, dataName=self.dataName)
369 fitPatch.return_value.saveChi2Contributions.assert_has_calls(expected)
371 def test_fit_astrometry_writeChi2(self):
372 """Test that we are calling saveChi2 with appropriate file prefixes."""
373 self.config.astrometryModel = "constrained"
374 self.config.writeChi2FilesOuterLoop = True
375 jointcal = lsst.jointcal.JointcalTask(config=self.config)
376 jointcal.focalPlaneBBox = lsst.geom.Box2D()
378 # Mock the fitter, so we can pretend it found a good fit
379 fitPatch = mock.patch("lsst.jointcal.AstrometryFit")
380 # Mock the projection handler so we don't segfault due to not-fully initialized ccdImages
381 projectorPatch = mock.patch("lsst.jointcal.OneTPPerVisitHandler")
382 with fitPatch as fit, projectorPatch as projector:
383 fit.return_value.computeChi2.return_value = self.goodChi2
384 fit.return_value.minimize.return_value = MinimizeResult.Converged
385 # return a real ProjectionHandler to keep ConstrainedAstrometryModel() happy
386 projector.return_value = lsst.jointcal.IdentityProjectionHandler()
388 # config.debugOutputPath is prepended to the filenames that go into saveChi2Contributions
389 expected = ["./astrometry_init-DistortionsVisit_chi2", "./astrometry_init-Distortions_chi2",
390 "./astrometry_init-Positions_chi2", "./astrometry_init-DistortionsPositions_chi2"]
391 expected = [mock.call(x+"-fake{type}") for x in expected]
392 jointcal._fit_astrometry(self.associations, dataName=self.dataName)
393 fit.return_value.saveChi2Contributions.assert_has_calls(expected)
396class TestComputeBoundingCircle(lsst.utils.tests.TestCase):
397 """Tests of Associations.computeBoundingCircle()"""
398 def _checkPointsInCircle(self, points, center, radius):
399 """Check that all points are within the (center, radius) circle.
401 The test is whether the max(points - center) separation is equal to
402 (or slightly less than) radius.
403 """
404 maxSeparation = 0*lsst.geom.degrees
405 for point in points:
406 maxSeparation = max(maxSeparation, center.separation(point))
407 self.assertAnglesAlmostEqual(maxSeparation, radius, maxDiff=3*lsst.geom.arcseconds)
408 self.assertLess(maxSeparation, radius)
410 def _testPoints(self, ccdImage1, ccdImage2, skyWcs1, skyWcs2, bbox):
411 """Fill an Associations object and test that it computes the correct
412 bounding circle for the input data.
414 Parameters
415 ----------
416 ccdImage1, ccdImage2 : `lsst.jointcal.CcdImage`
417 The CcdImages to add to the Associations object.
418 skyWcs1, skyWcs2 : `lsst.afw.geom.SkyWcs`
419 The WCS of each of the above images.
420 bbox : `lsst.geom.Box2D`
421 The ccd bounding box of both images.
422 """
423 lsst.log.setLevel('jointcal', lsst.log.DEBUG)
424 associations = lsst.jointcal.Associations()
425 associations.addCcdImage(ccdImage1)
426 associations.addCcdImage(ccdImage2)
427 associations.computeCommonTangentPoint()
429 circle = associations.computeBoundingCircle()
430 center = lsst.geom.SpherePoint(circle.getCenter())
431 radius = lsst.geom.Angle(circle.getOpeningAngle().asRadians(), lsst.geom.radians)
432 points = [lsst.geom.SpherePoint(skyWcs1.pixelToSky(lsst.geom.Point2D(x)))
433 for x in bbox.getCorners()]
434 points.extend([lsst.geom.SpherePoint(skyWcs2.pixelToSky(lsst.geom.Point2D(x)))
435 for x in bbox.getCorners()])
436 self._checkPointsInCircle(points, center, radius)
438 def testPoints(self):
439 """Test for points in an "easy" area, far from RA=0 or the poles."""
440 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages()
441 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1],
442 struct.skyWcs[0], struct.skyWcs[1], struct.bbox)
444 def testPointsRA0(self):
445 """Test for CcdImages crossing RA=0; this demonstrates a fix for
446 the bug described in DM-19802.
447 """
448 wcs1, wcs2 = make_fake_wcs()
450 # Put the visit boresights at the WCS origin, for consistency
451 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512,
452 date=DateTime(date=65321.1),
453 boresightRaDec=wcs1.getSkyOrigin())
454 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144,
455 date=DateTime(date=65322.1),
456 boresightRaDec=wcs1.getSkyOrigin())
458 struct = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
459 fakeVisitInfos=[visitInfo1, visitInfo2])
460 self._testPoints(struct.ccdImageList[0], struct.ccdImageList[1],
461 struct.skyWcs[0], struct.skyWcs[1], struct.bbox)
464class TestJointcalComputePMDate(JointcalTestBase, lsst.utils.tests.TestCase):
465 """Tests of jointcal._compute_proper_motion_epoch(), using fake dates."""
466 def test_compute_proper_motion_epoch(self):
467 mjds = np.array((65432.1, 66666.0, 55555.0, 44322.2))
469 wcs1, wcs2 = make_fake_wcs()
470 visitInfo1 = lsst.afw.image.VisitInfo(exposureId=30577512,
471 date=DateTime(date=mjds[0]),
472 boresightRaDec=wcs1.getSkyOrigin())
473 visitInfo2 = lsst.afw.image.VisitInfo(exposureId=30621144,
474 date=DateTime(date=mjds[1]),
475 boresightRaDec=wcs2.getSkyOrigin())
476 visitInfo3 = lsst.afw.image.VisitInfo(exposureId=30577513,
477 date=DateTime(date=mjds[2]),
478 boresightRaDec=wcs1.getSkyOrigin())
479 visitInfo4 = lsst.afw.image.VisitInfo(exposureId=30621145,
480 date=DateTime(date=mjds[3]),
481 boresightRaDec=wcs2.getSkyOrigin())
483 struct1 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
484 fakeVisitInfos=[visitInfo1, visitInfo2])
485 struct2 = lsst.jointcal.testUtils.createTwoFakeCcdImages(fakeWcses=[wcs1, wcs2],
486 fakeVisitInfos=[visitInfo3, visitInfo4])
487 ccdImageList = list(itertools.chain(struct1.ccdImageList, struct2.ccdImageList))
488 associations = lsst.jointcal.Associations()
489 for ccdImage in ccdImageList:
490 associations.addCcdImage(ccdImage)
491 associations.computeCommonTangentPoint()
493 jointcal = lsst.jointcal.JointcalTask(config=self.config)
494 result = jointcal._compute_proper_motion_epoch(ccdImageList)
495 self.assertEqual(result.jyear, (astropy.time.Time(mjds, format="mjd", scale="tai").jyear).mean())
498class MemoryTester(lsst.utils.tests.MemoryTestCase):
499 pass
502if __name__ == "__main__": 502 ↛ 503line 502 didn't jump to line 503, because the condition on line 502 was never true
503 lsst.utils.tests.init()
504 unittest.main()