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