Coverage for tests / test_propertyMapPlot.py: 15%
184 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 09:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 09:07 +0000
1# This file is part of analysis_tools.
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/>.
21import os
22import unittest
24import healsparse as hsp
25import matplotlib
26import matplotlib.pyplot as plt
27import numpy as np
28import skyproj
30import lsst.utils.tests
31from lsst.analysis.tools.atools.healSparsePropertyMap import (
32 PerTractPropertyMapTool,
33 SurveyWidePropertyMapTool,
34)
35from lsst.analysis.tools.tasks.propertyMapAnalysis import (
36 PerTractPropertyMapAnalysisConfig,
37 PerTractPropertyMapAnalysisTask,
38 SurveyWidePropertyMapAnalysisConfig,
39 SurveyWidePropertyMapAnalysisTask,
40)
41from lsst.daf.butler import Butler, DataCoordinate, DatasetType, DeferredDatasetHandle
42from lsst.daf.butler.tests.utils import makeTestTempDir, removeTestTempDir
43from lsst.skymap.discreteSkyMap import DiscreteSkyMap
45# No display needed.
46matplotlib.use("Agg")
48# Direcory where this file is located.
49ROOT = os.path.abspath(os.path.dirname(__file__))
52class PerTractPropertyMapAnalysisTaskTestCase(lsst.utils.tests.TestCase):
53 """PerTractPropertyMapAnalysisTask test case.
55 Notes
56 -----
57 While definitive tests are conducted in `ci_hsc` and `ci_imsim` using real
58 and simulated datasets to ensure thorough coverage, this test case is
59 designed to catch foundational issues like syntax errors or logical
60 inconsistencies in the way the plots are generated.
61 """
63 def setUp(self):
64 # Create a temporary directory to test in.
65 self.testDir = makeTestTempDir(ROOT)
67 # Create a butler in the test directory.
68 Butler.makeRepo(self.testDir)
69 butler = Butler(self.testDir, run="testrun")
71 # Make a dummy dataId.
72 dataId = {"band": "i", "skymap": "hsc_rings_v1", "tract": 1915}
73 dataId = DataCoordinate.standardize(dataId, universe=butler.dimensions)
75 # Configure the maps to be plotted.
76 config = PerTractPropertyMapAnalysisConfig()
78 # Set configurations sent to skyproj.
79 config.projectionKwargs = {"celestial": True, "gridlines": True}
80 config.colorbarKwargs = {"cmap": "viridis"}
82 # The entries in the 'atools' namespace must exactly match the dataset
83 # types.
84 config.atools.deepCoadd_exposure_time_map_sum = PerTractPropertyMapTool()
85 config.atools.deepCoadd_exposure_time_map_sum.nBinsHist = 24
86 config.atools.deepCoadd_psf_maglim_map_weighted_mean = PerTractPropertyMapTool()
87 config.atools.goodSeeingCoadd_dcr_dra_map_weighted_mean = PerTractPropertyMapTool()
89 # Generate a list of dataset type names.
90 names = [name for name in config.atools.fieldNames]
92 # Mock up corresponding HealSparseMaps and register them with the
93 # butler.
94 inputs = {}
95 for name, value in zip(names, np.linspace(1, 10, len(names))):
96 hspMap = hsp.HealSparseMap.make_empty(nside_coverage=32, nside_sparse=4096, dtype=np.float32)
97 hspMap[0:10000] = value
98 hspMap[100000:110000] = value + 1
99 hspMap[500000:510000] = value + 2
100 datasetType = DatasetType(name, [], "HealSparseMap", universe=butler.dimensions)
101 butler.registry.registerDatasetType(datasetType)
102 dataRef = butler.put(hspMap, datasetType)
103 # Keys in inputs are designed to reflect the task's connection
104 # names.
105 inputs[name] = DeferredDatasetHandle(butler=butler, ref=dataRef, parameters=None)
107 # Mock up the skymap and tractInfo.
108 skyMapConfig = DiscreteSkyMap.ConfigClass()
109 coords = [ # From the PS1 Medium-Deep fields.
110 (10.6750, 41.2667), # M31
111 (36.2074, -04.5833), # XMM-LSS
112 ]
113 skyMapConfig.raList = [c[0] for c in coords]
114 skyMapConfig.decList = [c[1] for c in coords]
115 skyMapConfig.radiusList = [2] * len(coords)
116 skyMapConfig.validate()
117 skymap = DiscreteSkyMap(config=skyMapConfig)
118 self.tractInfo = skymap.generateTract(0)
120 # Initialize the task and set class attributes for subsequent use.
121 task = PerTractPropertyMapAnalysisTask()
122 self.config = config
123 self.plotInfo = task.parsePlotInfo(inputs, dataId, list(inputs.keys()))
124 self.data = inputs
126 for atool in self.config.atools:
127 atool.finalize()
129 def tearDown(self):
130 del self.data
131 del self.config
132 del self.tractInfo
133 del self.plotInfo
134 removeTestTempDir(self.testDir)
135 del self.testDir
137 def test_PerTractPropertyMapAnalysisTask(self):
138 # Previously computed reference RGB fractions for the plots.
139 expectedRGBFractions = [
140 (0.3195957485702613, 0.3214485564746734, 0.3205870925245099),
141 (0.3196810334967318, 0.3215274948937909, 0.32067869434232027),
142 (0.31861285794526134, 0.3206199550653596, 0.3196505213439543),
143 (0.3185880596405229, 0.3206015032679739, 0.319619406147876),
144 (0.31843414266748354, 0.32044758629493475, 0.3194654891748366),
145 ]
147 plt.rcParams.update(plt.rcParamsDefault)
148 for name, atool, expectedRGBFraction in zip(
149 self.config.atools.fieldNames, self.config.atools, expectedRGBFractions
150 ):
151 # Run the task via butler using the tool.
152 result = atool(
153 data=self.data, tractInfo=self.tractInfo, plotConfig=self.config, plotInfo=self.plotInfo
154 )
155 key = atool.process.buildActions.data.mapKey + "_PerTractPropertyMapPlot"
157 # Check that the key is in the result.
158 self.assertIn(key, result)
160 # Check that the output is a matplotlib figure.
161 fig = result[key]
162 self.assertTrue(isinstance(fig, plt.Figure), msg=f"Figure {key} is not a matplotlib figure.")
164 # Assert the number of axes in the figure. At least not empty.
165 # Varies between 7 and 10 based on skyproj version.
166 self.assertGreaterEqual(len(fig.axes), 7, f"Got axes: {fig.axes}")
168 # Verify some configuration parameters.
169 binsCount = atool.nBinsHist
170 if "exposure" in name.lower():
171 self.assertEqual(binsCount, 24)
172 else:
173 self.assertEqual(binsCount, 100)
174 zoomFactors = self.config.zoomFactors
175 self.assertEqual(len(zoomFactors), 2)
177 # Extract the property name from the dataset type name and infer
178 # the x-axis label of the histogram.
179 propertyName = name.split("_map_")[0].split("Coadd_")[1].replace("_", " ")
180 xlabel = (
181 propertyName.title()
182 .replace("Psf", "PSF")
183 .replace("Dcr", "DCR")
184 .replace("Dra", r"$\Delta$RA")
185 .replace("Ddec", r"$\Delta$Dec")
186 .replace("E1", "e1")
187 .replace("E2", "e2")
188 )
190 # Validate the structure of the figure.
191 self._validateFigureStructure(fig, atool, binsCount, xlabel, zoomFactors)
193 # Validate the RGB fractions of the figure. The tolerance is set
194 # empirically.
195 self._validateRGBFractions(fig, expectedRGBFraction, rtol=5e-3)
197 @staticmethod
198 def _isHistogramAxes(ax, binsCount, legendLabels, errors):
199 """Checks if a given axis is a histogram axis based on specified
200 parameters.
202 Parameters
203 ----------
204 ax : `~matplotlib.axes.Axes`
205 The axis to be checked.
206 binsCount : `int`
207 The expected number of bins in the histogram.
208 legendLabels : `List` [`str`]
209 The expected labels in the histogram legend.
210 errors : `List` [`str`]
211 A list to append any errors found during the checks.
213 Returns
214 -------
215 None
216 Errors are appended to the provided `errors` list.
217 """
219 # Count rectangle and polygon patches.
220 nRectanglePatches = sum(1 for patch in ax.patches if isinstance(patch, matplotlib.patches.Rectangle))
221 nPolygonPatches = sum(1 for patch in ax.patches if isinstance(patch, matplotlib.patches.Polygon))
223 # Check for the number of rectangle patches for the filled histogram.
224 if nRectanglePatches != binsCount:
225 errors.append(
226 f"Expected {binsCount} rectangle patches in histogram, but found {nRectanglePatches}."
227 )
229 # Check for the number of polygon patches, i.e. the step histograms.
230 if nPolygonPatches != 2:
231 errors.append(f"Expected 2 polygon patches, but found {nPolygonPatches}.")
233 # Check for `fill_between` regions, represented by `PolyCollection`
234 # objects.
235 if len(ax.collections) != 2:
236 errors.append(f"Expected 2 `fill_between` regions but found {len(ax.collections)}.")
238 # Verify legend labels.
239 legend = ax.get_legend()
240 if not legend:
241 errors.append("Legend is missing in the histogram.")
242 else:
243 labels = [text.get_text() for text in legend.get_texts()]
244 if set(labels) != set(legendLabels):
245 errors.append(f"Expected legend labels {legendLabels} but found {labels}.")
247 @staticmethod
248 def _isColorbarAxes(ax):
249 return any(child.__class__.__name__ == "_ColorbarSpine" for child in ax.get_children())
251 def _validateFigureStructure(self, fig, atool, binsCount, xlabel, zoomFactors):
252 """Validates the structure of a given matplotlib figure generated by
253 the tool that is being tested.
255 Parameters
256 ----------
257 fig : `~matplotlib.figure.Figure`
258 The figure to be validated.
259 atool :
260 `~lsst.analysis.tools.atools.healSparsePropertyMap.
261 PerTractPropertyMapTool`
262 The tool that generated the figure.
263 binsCount : `int`
264 The expected number of bins in the histogram.
265 xlabel : `str`
266 The expected x-axis label of the histogram.
267 zoomFactors : `List` [`float`]
268 A list of zoom factors used for the zoomed-in plots.
270 Raises
271 ------
272 AssertionError
273 If any of the criteria for figure structure is not met. The error
274 message will list all criteria that were not satisfied.
275 """
276 errors = []
277 axes = fig.get_axes()
279 # Check the total number of each axis type.
280 totalSkyAxes = sum(isinstance(ax, skyproj.skyaxes.SkyAxes) for ax in axes)
281 totalColorbarAxes = sum(1 for ax in axes if self._isColorbarAxes(ax))
283 if totalSkyAxes != 3:
284 errors.append(f"Expected 3 SkyAxes but got {totalSkyAxes}.")
285 if totalColorbarAxes != 3:
286 errors.append(f"Expected 3 colorbar Axes but got {totalColorbarAxes}.")
288 # Check histogram axis.
289 self._isHistogramAxes(
290 axes[0],
291 binsCount,
292 ["Full Tract"]
293 + [f"{atool.produce.plot.prettyPrintFloat(factor)}x Zoom" for factor in zoomFactors],
294 errors,
295 )
297 # Verify x and y labels for histogram.
298 if axes[0].get_xlabel() != xlabel:
299 errors.append(f"Expected x-label '{xlabel}' for histogram but found '{axes[0].get_xlabel()}'.")
300 if axes[0].get_ylabel() != "Normalized Count":
301 errors.append(
302 f"Expected y-label 'Normalized Count' for histogram but found '{axes[0].get_ylabel()}'."
303 )
305 self.assertTrue(len(errors) == 0, msg="\n" + "\n".join(errors))
307 def _validateRGBFractions(self, fig, RGBFraction, rtol=1e-7):
308 """Checks if a matplotlib figure has specified fractions of R, G, and B
309 colors.
311 Parameters
312 ----------
313 fig : `~matplotlib.figure.Figure`
314 The figure to check.
315 RGBFraction : `tuple`
316 Tuple containing the desired fractions for red, green, and blue in
317 the image, respectively.
318 rtol : `float`, optional
319 The relative tolerance allowed for the fractions. Default is 1e-7.
321 Raises
322 ------
323 AssertionError
324 If the actual fractions of the RGB colors in the image do not match
325 the expected fractions within the given tolerance.
326 """
328 # Unpack the desired fractions.
329 rFraction, gFraction, bFraction = RGBFraction
331 # Draw the figure so the renderer can grab the pixel buffer.
332 fig.canvas.draw()
334 # Convert figure to data array.
335 data = np.array(fig.canvas.renderer.buffer_rgba())[:, :, :3] / 255.0
337 # Calculate fractions.
338 rActualFraction = np.sum(data[:, :, 0]) / data.size
339 gActualFraction = np.sum(data[:, :, 1]) / data.size
340 bActualFraction = np.sum(data[:, :, 2]) / data.size
342 # Check if the actual fractions meet the expected fractions within the
343 # given tolerance.
344 errors = []
345 if not np.abs(rActualFraction - rFraction) <= rtol:
346 errors.append(
347 f"Calculated red fraction {rActualFraction} does not match {rFraction} within rtol {rtol}."
348 )
350 if not np.abs(gActualFraction - gFraction) <= rtol:
351 errors.append(
352 f"Calculated green fraction {gActualFraction} does not match {gFraction} within rtol {rtol}."
353 )
355 if not np.abs(bActualFraction - bFraction) <= rtol:
356 errors.append(
357 f"Calculated blue fraction {bActualFraction} does not match {bFraction} within rtol {rtol}."
358 )
360 self.assertTrue(len(errors) == 0, msg="\n" + "\n".join(errors))
363class SurveyWidePropertyMapAnalysisTaskTestCase(lsst.utils.tests.TestCase):
364 """PerTractPropertyMapAnalysisTask test case.
366 Notes
367 -----
368 This is a basic functionality test to verify the internal workings of the
369 task.
370 """
372 def setUp(self):
373 # Create a temporary directory to test in.
374 self.testDir = makeTestTempDir(ROOT)
376 # Create a butler in the test directory.
377 Butler.makeRepo(self.testDir)
378 butler = Butler(self.testDir, run="testrun")
380 # Make a dummy dataId.
381 dataId = {"band": "i", "skymap": "hsc_rings_v1"}
382 dataId = DataCoordinate.standardize(dataId, universe=butler.dimensions)
384 # Configure the maps to be plotted.
385 config = SurveyWidePropertyMapAnalysisConfig()
387 # Set configurations sent to skyproj.
388 config.autozoom = True
389 config.projection = "Mollweide"
390 config.projectionKwargs = {"celestial": True, "gridlines": True, "lon_0": 0}
391 config.colorbarKwargs = {"location": "top", "cmap": "viridis"}
393 # The entries in the 'atools' namespace must exactly match the dataset
394 # type.
395 config.atools.deepCoadd_exposure_time_consolidated_map_sum = SurveyWidePropertyMapTool()
396 config.atools.deepCoadd_psf_maglim_consolidated_map_weighted_mean = SurveyWidePropertyMapTool()
397 config.atools.goodSeeingCoadd_dcr_dra_consolidated_map_weighted_mean = SurveyWidePropertyMapTool()
399 # Generate a list of dataset type names.
400 names = [name for name in config.atools.fieldNames]
402 # Mock up corresponding HealSparseMaps and register them with the
403 # butler.
404 inputs = {}
405 for name, value in zip(names, np.linspace(1, 10, len(names))):
406 hspMap = hsp.HealSparseMap.make_empty(nside_coverage=32, nside_sparse=4096, dtype=np.float32)
407 hspMap[0:10000] = value
408 hspMap[100000:110000] = value + 1
409 hspMap[500000:510000] = value + 2
410 datasetType = DatasetType(name, [], "HealSparseMap", universe=butler.dimensions)
411 butler.registry.registerDatasetType(datasetType)
412 dataRef = butler.put(hspMap, datasetType)
413 # Keys in inputs are designed to reflect the dataset type names.
414 inputs[name] = DeferredDatasetHandle(butler=butler, ref=dataRef, parameters=None)
416 # Initialize the task and set class attributes for subsequent use.
417 task = SurveyWidePropertyMapAnalysisTask()
418 self.config = config
419 self.plotInfo = task.parsePlotInfo(inputs, dataId, list(inputs.keys()))
420 self.data = inputs
422 for atool in self.config.atools:
423 atool.finalize()
425 def tearDown(self):
426 del self.data
427 del self.config
428 del self.plotInfo
429 removeTestTempDir(self.testDir)
430 del self.testDir
432 def test_SurveyWidePropertyMapAnalysisTask(self):
433 plt.rcParams.update(plt.rcParamsDefault)
434 for atool in self.config.atools:
435 # Run the task via butler using the tool.
436 result = atool(data=self.data, plotConfig=self.config, plotInfo=self.plotInfo)
437 key = atool.process.buildActions.data.mapKey + "_SurveyWidePropertyMapPlot"
439 # Check that the key is in the result.
440 self.assertIn(key, result)
442 # Check that the output is a matplotlib figure.
443 fig = result[key]
444 self.assertTrue(isinstance(fig, plt.Figure), msg=f"Figure {key} is not a matplotlib figure.")
446 # Assert the number of axes in the figure. At least not empty.
447 # There should be at least 2 axes.
448 # Some versions of skyproj have an additional axis.
449 self.assertGreaterEqual(len(fig.axes), 2, f"Got axes: {fig.axes}")
452class MemoryTester(lsst.utils.tests.MemoryTestCase):
453 pass
456def setup_module(module):
457 lsst.utils.tests.init()
460if __name__ == "__main__": 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 lsst.utils.tests.init()
462 unittest.main()