Coverage for tests / test_propertyMapPlot.py: 15%

184 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 09:01 +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 

23 

24import healsparse as hsp 

25import matplotlib 

26import matplotlib.pyplot as plt 

27import numpy as np 

28import skyproj 

29 

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 

44 

45# No display needed. 

46matplotlib.use("Agg") 

47 

48# Direcory where this file is located. 

49ROOT = os.path.abspath(os.path.dirname(__file__)) 

50 

51 

52class PerTractPropertyMapAnalysisTaskTestCase(lsst.utils.tests.TestCase): 

53 """PerTractPropertyMapAnalysisTask test case. 

54 

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 """ 

62 

63 def setUp(self): 

64 # Create a temporary directory to test in. 

65 self.testDir = makeTestTempDir(ROOT) 

66 

67 # Create a butler in the test directory. 

68 Butler.makeRepo(self.testDir) 

69 butler = Butler(self.testDir, run="testrun") 

70 

71 # Make a dummy dataId. 

72 dataId = {"band": "i", "skymap": "hsc_rings_v1", "tract": 1915} 

73 dataId = DataCoordinate.standardize(dataId, universe=butler.dimensions) 

74 

75 # Configure the maps to be plotted. 

76 config = PerTractPropertyMapAnalysisConfig() 

77 

78 # Set configurations sent to skyproj. 

79 config.projectionKwargs = {"celestial": True, "gridlines": True} 

80 config.colorbarKwargs = {"cmap": "viridis"} 

81 

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() 

88 

89 # Generate a list of dataset type names. 

90 names = [name for name in config.atools.fieldNames] 

91 

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) 

106 

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) 

119 

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 

125 

126 for atool in self.config.atools: 

127 atool.finalize() 

128 

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 

136 

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 ] 

146 

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" 

156 

157 # Check that the key is in the result. 

158 self.assertIn(key, result) 

159 

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.") 

163 

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}") 

167 

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) 

176 

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 ) 

189 

190 # Validate the structure of the figure. 

191 self._validateFigureStructure(fig, atool, binsCount, xlabel, zoomFactors) 

192 

193 # Validate the RGB fractions of the figure. The tolerance is set 

194 # empirically. 

195 self._validateRGBFractions(fig, expectedRGBFraction, rtol=5e-3) 

196 

197 @staticmethod 

198 def _isHistogramAxes(ax, binsCount, legendLabels, errors): 

199 """Checks if a given axis is a histogram axis based on specified 

200 parameters. 

201 

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. 

212 

213 Returns 

214 ------- 

215 None 

216 Errors are appended to the provided `errors` list. 

217 """ 

218 

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)) 

222 

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 ) 

228 

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}.") 

232 

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)}.") 

237 

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}.") 

246 

247 @staticmethod 

248 def _isColorbarAxes(ax): 

249 return any(child.__class__.__name__ == "_ColorbarSpine" for child in ax.get_children()) 

250 

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. 

254 

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. 

269 

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() 

278 

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)) 

282 

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}.") 

287 

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 ) 

296 

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 ) 

304 

305 self.assertTrue(len(errors) == 0, msg="\n" + "\n".join(errors)) 

306 

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. 

310 

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. 

320 

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 """ 

327 

328 # Unpack the desired fractions. 

329 rFraction, gFraction, bFraction = RGBFraction 

330 

331 # Draw the figure so the renderer can grab the pixel buffer. 

332 fig.canvas.draw() 

333 

334 # Convert figure to data array. 

335 data = np.array(fig.canvas.renderer.buffer_rgba())[:, :, :3] / 255.0 

336 

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 

341 

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 ) 

349 

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 ) 

354 

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 ) 

359 

360 self.assertTrue(len(errors) == 0, msg="\n" + "\n".join(errors)) 

361 

362 

363class SurveyWidePropertyMapAnalysisTaskTestCase(lsst.utils.tests.TestCase): 

364 """PerTractPropertyMapAnalysisTask test case. 

365 

366 Notes 

367 ----- 

368 This is a basic functionality test to verify the internal workings of the 

369 task. 

370 """ 

371 

372 def setUp(self): 

373 # Create a temporary directory to test in. 

374 self.testDir = makeTestTempDir(ROOT) 

375 

376 # Create a butler in the test directory. 

377 Butler.makeRepo(self.testDir) 

378 butler = Butler(self.testDir, run="testrun") 

379 

380 # Make a dummy dataId. 

381 dataId = {"band": "i", "skymap": "hsc_rings_v1"} 

382 dataId = DataCoordinate.standardize(dataId, universe=butler.dimensions) 

383 

384 # Configure the maps to be plotted. 

385 config = SurveyWidePropertyMapAnalysisConfig() 

386 

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"} 

392 

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() 

398 

399 # Generate a list of dataset type names. 

400 names = [name for name in config.atools.fieldNames] 

401 

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) 

415 

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 

421 

422 for atool in self.config.atools: 

423 atool.finalize() 

424 

425 def tearDown(self): 

426 del self.data 

427 del self.config 

428 del self.plotInfo 

429 removeTestTempDir(self.testDir) 

430 del self.testDir 

431 

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" 

438 

439 # Check that the key is in the result. 

440 self.assertIn(key, result) 

441 

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.") 

445 

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}") 

450 

451 

452class MemoryTester(lsst.utils.tests.MemoryTestCase): 

453 pass 

454 

455 

456def setup_module(module): 

457 lsst.utils.tests.init() 

458 

459 

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()