Coverage for tests/test_processStar_extra.py: 99%
316 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:09 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:09 +0000
1#!/usr/bin/env python
2# This file is part of atmospec.
3#
4# Developed for the LSST Data Management System.
5# This product includes software developed by the LSST Project
6# (https://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 <https://www.gnu.org/licenses/>.
22"""Coverage-oriented test cases for lsst.atmospec.processStar."""
24import types
25import unittest
26from unittest import mock
28import numpy as np
30import lsst.utils
31import lsst.utils.tests
32import lsst.afw.image as afwImage
33import lsst.geom as geom
34import lsst.afw.detection as afwDetect
35from lsst.afw.geom.ellipses import Quadrupole
36from lsst.pex.config import FieldValidationError
38from lsst.atmospec.processStar import (
39 ProcessStarTask,
40 ProcessStarTaskConfig,
41 ProcessStarTaskConnections,
42)
44MODULE = "lsst.atmospec.processStar"
47def makeSourceExposure(width=100, height=100):
48 """Build a small ExposureF containing three gaussian sources.
50 Returns the exposure. The sources are:
51 - a round, faint-ish source at (30, 30)
52 - an elongated, bright source at (70, 70)
53 - a round, very faint source at (50, 20)
54 """
55 exp = afwImage.ExposureF(width, height)
56 exp.variance.array[:] = 1.0
57 arr = exp.image.array
58 yy, xx = np.mgrid[0:height, 0:width]
60 def addGaussian(x, y, flux, sx, sy):
61 g = np.exp(-(((xx - x)**2) / (2 * sx**2)
62 + ((yy - y)**2) / (2 * sy**2)))
63 g *= flux / g.sum()
64 arr[:] = arr + g
66 addGaussian(30, 30, 10000, 2, 2)
67 addGaussian(70, 70, 50000, 2, 6)
68 addGaussian(50, 20, 500, 2, 2)
69 return exp
72def fakeFindConfig():
73 """A namespace supplying the object-finding config fields.
75 These fields are referenced by findObjects/findMainSource but are not
76 present on the real config, so tests inject this fake instead.
77 """
78 return types.SimpleNamespace(
79 mainStarNpixMin=5,
80 mainStarNsigma=5,
81 mainStarGrow=0,
82 mainStarGrowIsotropic=True,
83 mainSourceFindingMethod='ROUNDEST',
84 mainStarFluxCut=1e-15,
85 mainStarRoundnessCut=1e9,
86 )
89class ConfigTestCase(lsst.utils.tests.TestCase):
90 """Tests for ProcessStarTaskConfig and connections."""
92 def testDefaultsAndSetDefaults(self):
93 config = ProcessStarTaskConfig()
94 # setDefaults was applied during construction.
95 self.assertFalse(config.charImage.doWriteExposure)
96 self.assertFalse(config.charImage.doApCorr)
97 self.assertFalse(config.charImage.doMeasurePsf)
98 self.assertEqual(config.charImage.detection.includeThresholdMultiplier, 3)
100 def testValidatePasses(self):
101 config = ProcessStarTaskConfig()
102 # doFitAtmosphere is False by default so this should not raise.
103 config.validate()
105 def testValidateFitAtmosphereRaises(self):
106 config = ProcessStarTaskConfig()
107 config.doFitAtmosphere = True
108 with mock.patch(f"{MODULE}.shutil.which", return_value=None):
109 with self.assertRaises(FieldValidationError):
110 config.validate()
112 def testValidateFitAtmosphereOnSpectrogramRaises(self):
113 config = ProcessStarTaskConfig()
114 config.doFitAtmosphereOnSpectrogram = True
115 with mock.patch(f"{MODULE}.shutil.which", return_value=None):
116 with self.assertRaises(FieldValidationError):
117 config.validate()
119 def testValidateWithUvspecPresent(self):
120 config = ProcessStarTaskConfig()
121 config.doFitAtmosphere = True
122 config.doFitAtmosphereOnSpectrogram = True
123 with mock.patch(f"{MODULE}.shutil.which", return_value="/usr/bin/uvspec"):
124 config.validate()
126 def testConnectionsDefault(self):
127 config = ProcessStarTaskConfig()
128 connections = ProcessStarTaskConnections(config=config)
129 self.assertIn("spectrumForwardModelFitParameters", connections.outputs)
130 self.assertNotIn("spectrumLibradtranFitParameters", connections.outputs)
131 self.assertNotIn("spectrogramLibradtranFitParameters", connections.outputs)
133 def testConnectionsToggles(self):
134 config = ProcessStarTaskConfig()
135 config.doFullForwardModelDeconvolution = False
136 connections = ProcessStarTaskConnections(config=config)
137 self.assertNotIn("spectrumForwardModelFitParameters", connections.outputs)
140class ProcessStarTaskTestCase(lsst.utils.tests.TestCase):
141 """Tests for ProcessStarTask methods."""
143 def setUp(self):
144 self.config = ProcessStarTaskConfig()
145 self.task = ProcessStarTask(config=self.config)
147 def testInit(self):
148 self.assertTrue(hasattr(self.task, "isr"))
149 self.assertTrue(hasattr(self.task, "charImage"))
151 def testGetEllipticity(self):
152 # A round shape has (near) zero ellipticity.
153 roundShape = Quadrupole(4.0, 4.0, 0.0)
154 self.assertAlmostEqual(self.task._getEllipticity(roundShape), 0.0)
155 # An elongated shape has a positive ellipticity.
156 elongated = Quadrupole(10.0, 2.0, 0.0)
157 self.assertGreater(self.task._getEllipticity(elongated), 0.0)
159 def testFindObjects(self):
160 exp = makeSourceExposure()
161 self.task.config = fakeFindConfig()
162 fpSet = self.task.findObjects(exp)
163 self.assertIsInstance(fpSet, afwDetect.FootprintSet)
164 self.assertGreaterEqual(len(fpSet.getFootprints()), 1)
166 def testFindObjectsWithSigmaAndGrow(self):
167 exp = makeSourceExposure()
168 cfg = fakeFindConfig()
169 cfg.mainStarGrow = 2
170 self.task.config = cfg
171 # Pass explicit nSigma; grow taken from config (>0 branch).
172 fpSet = self.task.findObjects(exp, nSigma=5)
173 self.assertIsInstance(fpSet, afwDetect.FootprintSet)
175 def testGetRoundestObject(self):
176 exp = makeSourceExposure()
177 self.task.config = fakeFindConfig()
178 fpSet = self.task.findObjects(exp)
179 source = self.task.getRoundestObject(fpSet, exp, fluxCut=1e-15)
180 self.assertIsInstance(source, afwDetect.Footprint)
182 def testGetBrightestObject(self):
183 exp = makeSourceExposure()
184 self.task.config = fakeFindConfig()
185 fpSet = self.task.findObjects(exp)
186 source = self.task.getBrightestObject(fpSet, exp, roundnessCut=1e9)
187 self.assertIsInstance(source, afwDetect.Footprint)
189 def testFindMainSourceRoundest(self):
190 exp = makeSourceExposure()
191 cfg = fakeFindConfig()
192 cfg.mainSourceFindingMethod = 'ROUNDEST'
193 self.task.config = cfg
194 centroid = self.task.findMainSource(exp)
195 self.assertEqual(len(centroid), 2)
197 def testFindMainSourceBrightest(self):
198 exp = makeSourceExposure()
199 cfg = fakeFindConfig()
200 cfg.mainSourceFindingMethod = 'BRIGHTEST'
201 self.task.config = cfg
202 centroid = self.task.findMainSource(exp)
203 self.assertEqual(len(centroid), 2)
205 def testFindMainSourceInvalid(self):
206 exp = makeSourceExposure()
207 cfg = fakeFindConfig()
208 cfg.mainSourceFindingMethod = 'NONSENSE'
209 self.task.config = cfg
210 with self.assertRaises(RuntimeError):
211 self.task.findMainSource(exp)
213 def testUpdateMetadataWithCentroid(self):
214 exp = afwImage.ExposureF(10, 10)
215 vi = afwImage.VisitInfo(boresightAirmass=1.5)
216 exp.getInfo().setVisitInfo(vi)
217 self.task.updateMetadata(exp, centroid=(12.0, 34.0))
218 md = exp.getMetadata()
219 self.assertEqual(md['OBJECTX'], 12.0)
220 self.assertEqual(md['OBJECTY'], 34.0)
221 self.assertEqual(md['AIRMASS'], 1.5)
222 self.assertIn('HA', md)
224 def testUpdateMetadataWithoutCentroid(self):
225 exp = afwImage.ExposureF(10, 10)
226 vi = afwImage.VisitInfo(boresightAirmass=1.2)
227 exp.getInfo().setVisitInfo(vi)
228 self.task.updateMetadata(exp)
229 md = exp.getMetadata()
230 self.assertIsNone(md['OBJECTX'])
231 self.assertIsNone(md['OBJECTY'])
233 def testGetNormalizedTargetNameMapped(self):
234 # 'ETA1DOR' is mapped in nameMappings.txt.
235 result = self.task.getNormalizedTargetName('spec:ETA1DOR')
236 self.assertEqual(result, 'HD42525')
238 def testGetNormalizedTargetNameUnmapped(self):
239 result = self.task.getNormalizedTargetName('HD999999')
240 self.assertEqual(result, 'HD999999')
242 def testGetSpectractorTargetSettingAutoExact(self):
243 self.task.config = types.SimpleNamespace(targetCentroidMethod='auto')
244 result = self.task._getSpectractorTargetSetting({'astrometricMatch': True})
245 self.assertEqual(result, 'guess')
247 def testGetSpectractorTargetSettingAutoFit(self):
248 self.task.config = types.SimpleNamespace(targetCentroidMethod='auto')
249 result = self.task._getSpectractorTargetSetting({'astrometricMatch': False})
250 self.assertEqual(result, 'fit')
252 def testGetSpectractorTargetSettingExact(self):
253 self.task.config = types.SimpleNamespace(targetCentroidMethod='exact')
254 self.assertEqual(self.task._getSpectractorTargetSetting({}), 'guess')
256 def testGetSpectractorTargetSettingFallthrough(self):
257 self.task.config = types.SimpleNamespace(targetCentroidMethod='WCS')
258 self.assertEqual(self.task._getSpectractorTargetSetting({}), 'WCS')
260 def testLoadStarNames(self):
261 names = self.task.loadStarNames()
262 self.assertIsInstance(names, list)
263 self.assertTrue(len(names) > 0)
265 def testFlatfield(self):
266 exp = afwImage.ExposureF(5, 5)
267 self.assertIs(self.task.flatfield(exp, None), exp)
269 def testRepairCosmics(self):
270 exp = afwImage.ExposureF(5, 5)
271 self.assertIs(self.task.repairCosmics(exp, None), exp)
273 def testMeasureSpectrum(self):
274 self.task.extraction = mock.MagicMock()
275 sentinel = object()
276 self.task.extraction.getFluxBasic.return_value = sentinel
277 result = self.task.measureSpectrum("exp", "cen", "bbox", "disp")
278 self.assertIs(result, sentinel)
279 self.task.extraction.initialise.assert_called_once()
281 def testCalcSpectrumBBoxPositiveOrder(self):
282 exp = afwImage.ExposureF(1000, 6000)
283 bbox = self.task.calcSpectrumBBox(exp, (500, 500), 40, order='+1')
284 self.assertIsInstance(bbox, geom.Box2I)
285 self.assertEqual(bbox.getMinY(), 600)
287 def testCalcSpectrumBBoxNegativeOrder(self):
288 exp = afwImage.ExposureF(1000, 6000)
289 bbox = self.task.calcSpectrumBBox(exp, (500, 5500), 40, order='-1')
290 self.assertIsInstance(bbox, geom.Box2I)
291 # yStart is clamped to a value >= 0.
292 self.assertGreaterEqual(bbox.getMinY(), 0)
294 def testPauseDefault(self):
295 self.task.debug = types.SimpleNamespace(pauseOnDisplay=False)
296 self.assertIsNone(self.task.pause())
298 def testPauseWithInput(self):
299 self.task.debug = types.SimpleNamespace(pauseOnDisplay=True)
300 with mock.patch("builtins.input", return_value=""):
301 self.assertIsNone(self.task.pause())
303 def testRunQuantum(self):
304 butlerQC = mock.MagicMock()
305 inputRefs = mock.MagicMock()
306 outputRefs = mock.MagicMock()
307 butlerQC.get.return_value = {
308 'inputExp': 'exp',
309 'inputCentroid': 'cen',
310 }
311 self.task.run = mock.MagicMock(return_value="outputs")
312 self.task.runQuantum(butlerQC, inputRefs, outputRefs)
313 self.task.run.assert_called_once()
314 _, kwargs = self.task.run.call_args
315 self.assertIn('dataIdDict', kwargs)
316 butlerQC.put.assert_called_once_with("outputs", outputRefs)
318 def _makeRunExposure(self, objectName='HD12345'):
319 exp = afwImage.ExposureF(10, 10)
320 vi = afwImage.VisitInfo(boresightAirmass=1.5, object=objectName)
321 exp.getInfo().setVisitInfo(vi)
322 return exp
324 def _runWithMocks(self, exp, inputCentroid, grating='other'):
325 with mock.patch(f"{MODULE}.isDispersedExp", return_value=True), \
326 mock.patch(f"{MODULE}.getLinearStagePosition", return_value=10.0), \
327 mock.patch(f"{MODULE}.getFilterAndDisperserFromExp",
328 return_value=('SDSSr', grating)), \
329 mock.patch(f"{MODULE}.SpectractorShim") as shim:
330 instance = shim.return_value
331 instance.run.return_value = mock.MagicMock()
332 result = self.task.run(inputExp=exp, inputCentroid=inputCentroid,
333 dataIdDict={'visit': 1})
334 return result, shim, instance
336 def testRunNotDispersedRaises(self):
337 exp = self._makeRunExposure()
338 with mock.patch(f"{MODULE}.isDispersedExp", return_value=False):
339 with self.assertRaises(RuntimeError):
340 self.task.run(inputExp=exp, inputCentroid={}, dataIdDict={})
342 def testRunBadObjectRaises(self):
343 exp = self._makeRunExposure(objectName='Test')
344 with mock.patch(f"{MODULE}.isDispersedExp", return_value=True), \
345 mock.patch(f"{MODULE}.getLinearStagePosition", return_value=10.0), \
346 mock.patch(f"{MODULE}.getFilterAndDisperserFromExp",
347 return_value=('SDSSr', 'other')):
348 with self.assertRaises(ValueError):
349 self.task.run(inputExp=exp,
350 inputCentroid={'astrometricMatch': True,
351 'centroid': (1, 2)},
352 dataIdDict={})
354 def testRunWithDictCentroid(self):
355 exp = self._makeRunExposure()
356 inputCentroid = {'astrometricMatch': True, 'centroid': (5.0, 6.0)}
357 result, shim, instance = self._runWithMocks(exp, inputCentroid)
358 shim.assert_called_once()
359 instance.run.assert_called_once()
360 self.assertTrue(hasattr(result, "spectractorSpectrum"))
362 def testRunWithTupleCentroid(self):
363 # A raw tuple centroid is only meaningful when the centroid method is
364 # not 'auto' (auto expects the dict form), so use an 'exact' config.
365 config = ProcessStarTaskConfig()
366 config.targetCentroidMethod = 'exact'
367 self.task = ProcessStarTask(config=config)
368 exp = self._makeRunExposure()
369 inputCentroid = (5.0, 6.0)
370 result, shim, instance = self._runWithMocks(exp, inputCentroid)
371 instance.run.assert_called_once()
373 def testRunHologramGrating(self):
374 exp = self._makeRunExposure()
375 inputCentroid = {'astrometricMatch': False, 'centroid': (5.0, 6.0)}
376 result, shim, instance = self._runWithMocks(exp, inputCentroid,
377 grating='holo4_003')
378 # 4mm window added to the linear stage position of 10.0
379 _, kwargs = shim.call_args
380 self.assertEqual(kwargs['paramOverrides']['DISTANCE2CCD'], 14.0)
382 def testRunForceObjectName(self):
383 config = ProcessStarTaskConfig()
384 config.forceObjectName = 'HD54321'
385 task = ProcessStarTask(config=config)
386 exp = self._makeRunExposure()
387 inputCentroid = {'astrometricMatch': True, 'centroid': (5.0, 6.0)}
388 with mock.patch(f"{MODULE}.isDispersedExp", return_value=True), \
389 mock.patch(f"{MODULE}.getLinearStagePosition", return_value=10.0), \
390 mock.patch(f"{MODULE}.getFilterAndDisperserFromExp",
391 return_value=('SDSSr', 'other')), \
392 mock.patch(f"{MODULE}.SpectractorShim") as shim:
393 shim.return_value.run.return_value = mock.MagicMock()
394 task.run(inputExp=exp, inputCentroid=inputCentroid,
395 dataIdDict={'visit': 1})
396 args, _ = shim.return_value.run.call_args
397 # target is the final positional argument passed to shim.run
398 self.assertEqual(args[3], 'HD54321')
400 def testRunAstrometrySuccess(self):
401 exp = afwImage.ExposureF(10, 10)
402 with mock.patch(f"{MODULE}.ReferenceObjectLoader"), \
403 mock.patch(f"{MODULE}.FitAffineWcsTask"), \
404 mock.patch(f"{MODULE}.AstrometryTask") as astromTask:
405 solver = astromTask.return_value
406 astromResult = mock.MagicMock()
407 astromResult.scatterOnSky.asArcseconds.return_value = 0.5
408 solver.run.return_value = astromResult
409 result = self.task.runAstrometry(butler=None, exp=exp, icSrc=None)
410 self.assertIs(result, astromResult)
412 def testRunAstrometryPoorScatter(self):
413 exp = afwImage.ExposureF(10, 10)
414 with mock.patch(f"{MODULE}.ReferenceObjectLoader"), \
415 mock.patch(f"{MODULE}.FitAffineWcsTask"), \
416 mock.patch(f"{MODULE}.AstrometryTask") as astromTask:
417 solver = astromTask.return_value
418 astromResult = mock.MagicMock()
419 astromResult.scatterOnSky.asArcseconds.return_value = 5.0
420 solver.run.return_value = astromResult
421 result = self.task.runAstrometry(butler=None, exp=exp, icSrc=None)
422 self.assertIsNone(result)
424 def testRunAstrometryFailure(self):
425 exp = afwImage.ExposureF(10, 10)
426 with mock.patch(f"{MODULE}.ReferenceObjectLoader"), \
427 mock.patch(f"{MODULE}.FitAffineWcsTask"), \
428 mock.patch(f"{MODULE}.AstrometryTask") as astromTask:
429 solver = astromTask.return_value
430 solver.run.side_effect = RuntimeError("boom")
431 result = self.task.runAstrometry(butler=None, exp=exp, icSrc=None)
432 self.assertIsNone(result)
435class InitDebugTestCase(lsst.utils.tests.TestCase):
436 """Exercise the debug-display branches of ProcessStarTask.__init__."""
438 def _makeDebugInfo(self, **kwargs):
439 defaults = dict(enabled=False, display=False, displayBackend='virtualDevice',
440 notHeadless=False, pauseOnDisplay=False)
441 defaults.update(kwargs)
442 return types.SimpleNamespace(**defaults)
444 def testInitDebugEnabledNoDisplay(self):
445 debug = self._makeDebugInfo(enabled=True, display=False, notHeadless=True)
446 with mock.patch(f"{MODULE}.lsstDebug.Info", return_value=debug):
447 task = ProcessStarTask(config=ProcessStarTaskConfig())
448 self.assertTrue(task.debug.enabled)
450 def testInitDebugDisplaySuccess(self):
451 import lsst.afw.display as afwDisp
452 debug = self._makeDebugInfo(enabled=True, display=True, notHeadless=False)
453 with mock.patch(f"{MODULE}.lsstDebug.Info", return_value=debug), \
454 mock.patch.object(afwDisp, "setDefaultBackend"), \
455 mock.patch.object(afwDisp, "setDefaultMaskTransparency"), \
456 mock.patch.object(afwDisp, "Display") as displayMock:
457 task = ProcessStarTask(config=ProcessStarTaskConfig())
458 self.assertTrue(task.debug.display)
459 displayMock.assert_called()
461 def testInitDebugDisplayNameError(self):
462 # Force the display setup to raise NameError, hitting the except block.
463 import lsst.afw.display as afwDisp
464 debug = self._makeDebugInfo(enabled=True, display=True, notHeadless=False)
465 with mock.patch(f"{MODULE}.lsstDebug.Info", return_value=debug), \
466 mock.patch.object(afwDisp, "setDefaultBackend",
467 side_effect=NameError("boom")):
468 task = ProcessStarTask(config=ProcessStarTaskConfig())
469 # display is disabled after the NameError.
470 self.assertFalse(task.debug.display)
473class TestMemory(lsst.utils.tests.MemoryTestCase):
474 pass
477def setup_module(module):
478 lsst.utils.tests.init()
481if __name__ == "__main__": 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true
482 lsst.utils.tests.init()
483 unittest.main()