Coverage for tests/test_trailedSources.py: 27%
204 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 02:49 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 02:49 -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
37# Trailed-source length, angle, and centroid.
38rng = np.random.default_rng(432)
39nTrails = 50
40Ls = rng.uniform(2, 20, nTrails)
41thetas = rng.uniform(0, 2*np.pi, nTrails)
42xcs = rng.uniform(0, 100, nTrails)
43ycs = rng.uniform(0, 100, nTrails)
46class TrailedSource:
47 """Holds a set of true trail parameters.
48 """
50 def __init__(self, instFlux, length, angle, xc, yc):
51 self.instFlux = instFlux
52 self.length = length
53 self.angle = angle
54 self.center = lsst.geom.Point2D(xc, yc)
55 self.x0 = xc - length/2 * np.cos(angle)
56 self.y0 = yc - length/2 * np.sin(angle)
57 self.x1 = xc + length/2 * np.cos(angle)
58 self.y1 = yc + length/2 * np.sin(angle)
61# "Extend" meas.base.tests.TestDataset
62class TrailedTestDataset(lsst.meas.base.tests.TestDataset):
63 """A dataset for testing trailed source measurements.
64 Given a `TrailedSource`, construct a record of the true values and an
65 Exposure.
66 """
68 def __init__(self, bbox, threshold=10.0, exposure=None, **kwds):
69 super().__init__(bbox, threshold, exposure, **kwds)
71 def addTrailedSource(self, trail):
72 """Add a trailed source to the simulation.
73 'Re-implemented' version of
74 `lsst.meas.base.tests.TestDataset.addSource`. Numerically integrates a
75 Gaussian PSF over a line to obtain am image of a trailed source.
76 """
78 record = self.catalog.addNew()
79 record.set(self.keys["centroid"], trail.center)
80 rng = np.random.default_rng(32)
81 covariance = rng.normal(0, 0.1, 4).reshape(2, 2)
82 covariance[0, 1] = covariance[1, 0]
83 record.set(self.keys["centroid_sigma"], covariance.astype(np.float32))
84 record.set(self.keys["shape"], self.psfShape)
85 record.set(self.keys["isStar"], False)
87 # Sum the psf at each
88 numIter = int(10*trail.length)
89 xp = np.linspace(trail.x0, trail.x1, num=numIter)
90 yp = np.linspace(trail.y0, trail.y1, num=numIter)
91 for (x, y) in zip(xp, yp):
92 pt = lsst.geom.Point2D(x, y)
93 im = self.drawGaussian(self.exposure.getBBox(), trail.instFlux,
94 lsst.afw.geom.Ellipse(self.psfShape, pt))
95 self.exposure.getMaskedImage().getImage().getArray()[:, :] += im.getArray()
97 totFlux = self.exposure.image.array.sum()
98 self.exposure.image.array /= totFlux
99 self.exposure.image.array *= trail.instFlux
101 record.set(self.keys["instFlux"], trail.instFlux)
102 self._installFootprint(record, self.exposure.getImage())
104 return record, self.exposure.getImage()
107# Following from meas_base/test_NaiveCentroid.py
108# Taken from NaiveCentroidTestCase
109@classParameters(length=Ls, theta=thetas, xc=xcs, yc=ycs)
110class TrailedSourcesTestCase(AlgorithmTestCase, lsst.utils.tests.TestCase):
112 def setUp(self):
113 self.center = lsst.geom.Point2D(50.1, 49.8)
114 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(-20, -30),
115 lsst.geom.Extent2I(140, 160))
116 self.dataset = TrailedTestDataset(self.bbox)
118 self.trail = TrailedSource(100000.0, self.length, self.theta, self.xc, self.yc)
119 self.dataset.addTrailedSource(self.trail)
121 def tearDown(self):
122 del self.center
123 del self.bbox
124 del self.trail
125 del self.dataset
127 @staticmethod
128 def transformMoments(Ixx, Iyy, Ixy):
129 """Transform second-moments to semi-major and minor axis.
130 """
131 xmy = Ixx - Iyy
132 xpy = Ixx + Iyy
133 xmy2 = xmy*xmy
134 xy2 = Ixy*Ixy
135 a2 = 0.5 * (xpy + np.sqrt(xmy2 + 4.0*xy2))
136 b2 = 0.5 * (xpy - np.sqrt(xmy2 + 4.0*xy2))
137 return a2, b2
139 @staticmethod
140 def f_length(x):
141 return sfntp.findLength(*x)[0]
143 @staticmethod
144 def g_length(x):
145 return sfntp.findLength(*x)[1]
147 @staticmethod
148 def f_flux(x, model):
149 return model.computeFluxWithGradient(x)[0]
151 @staticmethod
152 def g_flux(x, model):
153 return model.computeFluxWithGradient(x)[1]
155 @staticmethod
156 def central_difference(func, x, *args, h=1e-8):
157 result = np.zeros(len(x))
158 for i in range(len(x)):
159 xp = x.copy()
160 xp[i] += h
161 fp = func(xp, *args)
163 xm = x.copy()
164 xm[i] -= h
165 fm = func(xm, *args)
166 result[i] = (fp - fm) / (2*h)
168 return result
170 def makeTrailedSourceMeasurementTask(self, plugin=None, dependencies=(),
171 config=None, schema=None, algMetadata=None):
172 """Set up a measurement task for a trailed source plugin.
173 """
175 config = self.makeSingleFrameMeasurementConfig(plugin=plugin,
176 dependencies=dependencies)
178 # Make sure the shape slot is base_SdssShape
179 config.slots.shape = "base_SdssShape"
180 return self.makeSingleFrameMeasurementTask(plugin=plugin,
181 dependencies=dependencies,
182 config=config, schema=schema,
183 algMetadata=algMetadata)
185 def testNaivePlugin(self):
186 """Test the NaivePlugin measurements.
187 Given a `TrailedTestDataset`, run the NaivePlugin measurement and
188 compare the measured parameters to the true values.
189 """
191 # Set up and run Naive measurement.
192 task = self.makeTrailedSourceMeasurementTask(
193 plugin="ext_trailedSources_Naive",
194 dependencies=("base_SdssCentroid", "base_SdssShape")
195 )
196 exposure, catalog = self.dataset.realize(10.0, task.schema, randomSeed=0)
197 task.run(catalog, exposure)
198 record = catalog[0]
200 # Check the RA and Dec measurements
201 wcs = exposure.getWcs()
202 spt = wcs.pixelToSky(self.center)
203 ra_true = spt.getRa().asDegrees()
204 dec_true = spt.getDec().asDegrees()
205 ra_meas = record.get("ext_trailedSources_Naive_ra")
206 dec_meas = record.get("ext_trailedSources_Naive_dec")
207 self.assertFloatsAlmostEqual(ra_true, ra_meas, atol=None, rtol=0.01)
208 self.assertFloatsAlmostEqual(dec_true, dec_meas, atol=None, rtol=0.01)
210 # Check that root finder converged
211 converged = record.get("ext_trailedSources_Naive_flag_noConverge")
212 self.assertFalse(converged)
214 # Compare true with measured length, angle, and flux.
215 # Accuracy is dependent on the second-moments measurements, so the
216 # rtol values are simply rough upper bounds.
217 length = record.get("ext_trailedSources_Naive_length")
218 theta = record.get("ext_trailedSources_Naive_angle")
219 flux = record.get("ext_trailedSources_Naive_flux")
220 self.assertFloatsAlmostEqual(length, self.trail.length, atol=None, rtol=0.1)
221 self.assertFloatsAlmostEqual(theta % np.pi, self.trail.angle % np.pi,
222 atol=np.arctan(1/length), rtol=None)
223 self.assertFloatsAlmostEqual(flux, self.trail.instFlux, atol=None, rtol=0.1)
225 # Test function gradients versus finite difference derivatives
226 # Do length first
227 Ixx, Iyy, Ixy = record.getShape().getParameterVector()
228 a2, b2 = self.transformMoments(Ixx, Iyy, Ixy)
229 self.assertLessEqual(check_grad(self.f_length, self.g_length, [a2, b2]), 1e-6)
231 # Now flux gradient
232 xc = record.get("base_SdssShape_x")
233 yc = record.get("base_SdssShape_y")
234 params = np.array([xc, yc, 1.0, length, theta])
235 cutout = getMeasurementCutout(record, exposure)
236 model = VeresModel(cutout)
237 gradNum = self.central_difference(self.f_flux, params, model, h=9e-5)
238 gradMax = np.max(np.abs(gradNum - self.g_flux(params, model)))
239 self.assertLessEqual(gradMax, 1e-5)
241 # Check test setup
242 self.assertNotEqual(length, self.trail.length)
243 self.assertNotEqual(theta, self.trail.angle)
245 # Make sure measurement flag is False
246 self.assertFalse(record.get("ext_trailedSources_Naive_flag"))
248 def testVeresPlugin(self):
249 """Test the VeresPlugin measurements.
250 Given a `TrailedTestDataset`, run the VeresPlugin measurement and
251 compare the measured parameters to the true values.
252 """
254 # Set up and run Veres measurement.
255 task = self.makeTrailedSourceMeasurementTask(
256 plugin="ext_trailedSources_Veres",
257 dependencies=(
258 "base_SdssCentroid",
259 "base_SdssShape",
260 "ext_trailedSources_Naive")
261 )
262 exposure, catalog = self.dataset.realize(10.0, task.schema, randomSeed=0)
263 task.run(catalog, exposure)
264 record = catalog[0]
266 # Make sure optmizer converged
267 converged = record.get("ext_trailedSources_Veres_flag_nonConvergence")
268 self.assertFalse(converged)
270 # Compare measured trail length, angle, and flux to true values
271 # These measurements should perform at least as well as NaivePlugin
272 length = record.get("ext_trailedSources_Veres_length")
273 theta = record.get("ext_trailedSources_Veres_angle")
274 flux = record.get("ext_trailedSources_Veres_flux")
275 self.assertFloatsAlmostEqual(length, self.trail.length, atol=None, rtol=0.1)
276 self.assertFloatsAlmostEqual(theta % np.pi, self.trail.angle % np.pi,
277 atol=np.arctan(1/length), rtol=None)
278 self.assertFloatsAlmostEqual(flux, self.trail.instFlux, atol=None, rtol=0.1)
280 xc = record.get("ext_trailedSources_Veres_centroid_x")
281 yc = record.get("ext_trailedSources_Veres_centroid_y")
282 params = np.array([xc, yc, flux, length, theta])
283 cutout = getMeasurementCutout(record, exposure)
284 model = VeresModel(cutout)
285 gradNum = self.central_difference(model, params, h=1e-6)
286 gradMax = np.max(np.abs(gradNum - model.gradient(params)))
287 self.assertLessEqual(gradMax, 1e-5)
289 # Make sure test setup is working as expected
290 self.assertNotEqual(length, self.trail.length)
291 self.assertNotEqual(theta, self.trail.angle)
293 # Test that reduced chi-squared is reasonable
294 rChiSq = record.get("ext_trailedSources_Veres_rChiSq")
295 self.assertGreater(rChiSq, 0.8)
296 self.assertLess(rChiSq, 1.3)
298 # Make sure measurement flag is False
299 self.assertFalse(record.get("ext_trailedSources_Veres_flag"))
301 def testMonteCarlo(self):
302 """Test the uncertainties in trail measurements from NaivePlugin
303 """
304 # Adapted from lsst.meas.base
306 # Set up Naive measurement and dependencies.
307 task = self.makeTrailedSourceMeasurementTask(
308 plugin="ext_trailedSources_Naive",
309 dependencies=("base_SdssCentroid", "base_SdssShape")
310 )
312 nSamples = 2000
313 catalog = afwTable.SourceCatalog(task.schema)
314 sample = 0
315 seed = 0
316 while sample < nSamples:
317 seed += 1
318 exp, cat = self.dataset.realize(100.0, task.schema, randomSeed=seed)
319 rec = cat[0]
320 task.run(cat, exp)
322 # Accuracy of this measurement is entirely dependent on shape and
323 # centroiding. Skip when shape measurement fails.
324 if rec['base_SdssShape_flag']:
325 continue
326 catalog.append(rec)
327 sample += 1
329 catalog = catalog.copy(deep=True)
330 nameBase = "ext_trailedSources_Naive_"
332 # Currently, the errors don't include covariances, so just make sure
333 # we're close or at least over estimate
334 length = catalog[nameBase+"length"]
335 lengthErr = catalog[nameBase+"lengthErr"]
336 lengthStd = np.nanstd(length)
337 lengthErrMean = np.nanmean(lengthErr)
338 diff = (lengthErrMean - lengthStd) / lengthErrMean
339 self.assertGreater(diff, -0.1)
340 self.assertLess(diff, 0.5)
342 angle = catalog[nameBase+"angle"]
343 if (np.max(angle) - np.min(angle)) > np.pi/2:
344 angle = angle % np.pi # Wrap if bimodal
345 angleErr = catalog[nameBase+"angleErr"]
346 angleStd = np.nanstd(angle)
347 angleErrMean = np.nanmean(angleErr)
348 diff = (angleErrMean - angleStd) / angleErrMean
349 self.assertGreater(diff, -0.1)
350 self.assertLess(diff, 0.6)
353class TestMemory(lsst.utils.tests.MemoryTestCase):
354 pass
357def setup_module(module):
358 lsst.utils.tests.init()
361if __name__ == "__main__": 361 ↛ 362line 361 didn't jump to line 362, because the condition on line 361 was never true
362 lsst.utils.tests.init()
363 unittest.main()