Coverage for tests/test_trailedSources.py: 25%
205 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-14 03:08 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-14 03:08 -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#
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
36import lsst.log
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)
47class TrailedSource:
48 """Holds a set of true trail parameters.
49 """
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)
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 """
69 def __init__(self, bbox, threshold=10.0, exposure=None, **kwds):
70 super().__init__(bbox, threshold, exposure, **kwds)
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 """
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)
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()
98 totFlux = self.exposure.image.array.sum()
99 self.exposure.image.array /= totFlux
100 self.exposure.image.array *= trail.instFlux
102 record.set(self.keys["instFlux"], trail.instFlux)
103 self._installFootprint(record, self.exposure.getImage())
105 return record, self.exposure.getImage()
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):
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)
119 self.trail = TrailedSource(100000.0, self.length, self.theta, self.xc, self.yc)
120 self.dataset.addTrailedSource(self.trail)
122 def tearDown(self):
123 del self.center
124 del self.bbox
125 del self.trail
126 del self.dataset
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
140 @staticmethod
141 def f_length(x):
142 return sfntp.findLength(*x)[0]
144 @staticmethod
145 def g_length(x):
146 return sfntp.findLength(*x)[1]
148 @staticmethod
149 def f_flux(x, model):
150 return model.computeFluxWithGradient(x)[0]
152 @staticmethod
153 def g_flux(x, model):
154 return model.computeFluxWithGradient(x)[1]
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)
164 xm = x.copy()
165 xm[i] -= h
166 fm = func(xm, *args)
167 result[i] = (fp - fm) / (2*h)
169 return result
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 """
176 config = self.makeSingleFrameMeasurementConfig(plugin=plugin,
177 dependencies=dependencies)
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)
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 """
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]
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)
211 # Check that root finder converged
212 converged = record.get("ext_trailedSources_Naive_flag_noConverge")
213 self.assertFalse(converged)
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)
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)
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)
242 # Check test setup
243 self.assertNotEqual(length, self.trail.length)
244 self.assertNotEqual(theta, self.trail.angle)
246 # Make sure measurement flag is False
247 self.assertFalse(record.get("ext_trailedSources_Naive_flag"))
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 """
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]
267 # Make sure optmizer converged
268 converged = record.get("ext_trailedSources_Veres_flag_nonConvergence")
269 self.assertFalse(converged)
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)
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)
290 # Make sure test setup is working as expected
291 self.assertNotEqual(length, self.trail.length)
292 self.assertNotEqual(theta, self.trail.angle)
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)
299 # Make sure measurement flag is False
300 self.assertFalse(record.get("ext_trailedSources_Veres_flag"))
302 def testMonteCarlo(self):
303 """Test the uncertainties in trail measurements from NaivePlugin
304 """
305 # Adapted from lsst.meas.base
307 # Set up Naive measurement and dependencies.
308 task = self.makeTrailedSourceMeasurementTask(
309 plugin="ext_trailedSources_Naive",
310 dependencies=("base_SdssCentroid", "base_SdssShape")
311 )
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)
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
330 catalog = catalog.copy(deep=True)
331 nameBase = "ext_trailedSources_Naive_"
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)
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)
354class TestMemory(lsst.utils.tests.MemoryTestCase):
355 pass
358def setup_module(module):
359 lsst.utils.tests.init()
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()