Coverage for tests / test_propertyMapPlot.py: 15%

184 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 18:53 +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 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 

43 

44# No display needed. 

45matplotlib.use("Agg") 

46 

47# Direcory where this file is located. 

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

49 

50 

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

52 """PerTractPropertyMapAnalysisTask test case. 

53 

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

61 

62 def setUp(self): 

63 # Create a temporary directory to test in. 

64 self.testDir = makeTestTempDir(ROOT) 

65 

66 # Create a butler in the test directory. 

67 Butler.makeRepo(self.testDir) 

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

69 

70 # Make a dummy dataId. 

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

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

73 

74 # Configure the maps to be plotted. 

75 config = PerTractPropertyMapAnalysisConfig() 

76 

77 # Set configurations sent to skyproj. 

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

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

80 

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

87 

88 # Generate a list of dataset type names. 

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

90 

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) 

105 

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) 

118 

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 

124 

125 for atool in self.config.atools: 

126 atool.finalize() 

127 

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 

135 

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 ] 

145 

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" 

155 

156 # Check that the key is in the result. 

157 self.assertIn(key, result) 

158 

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

162 

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

166 

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) 

175 

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 ) 

188 

189 # Validate the structure of the figure. 

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

191 

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

193 # empirically. 

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

195 

196 @staticmethod 

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

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

199 parameters. 

200 

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. 

211 

212 Returns 

213 ------- 

214 None 

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

216 """ 

217 

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

221 

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 ) 

227 

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

231 

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

236 

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

245 

246 @staticmethod 

247 def _isColorbarAxes(ax): 

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

249 

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. 

253 

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. 

268 

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

277 

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

281 

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

286 

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 ) 

295 

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 ) 

303 

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

305 

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. 

309 

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. 

319 

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

326 

327 # Unpack the desired fractions. 

328 rFraction, gFraction, bFraction = RGBFraction 

329 

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

331 fig.canvas.draw() 

332 

333 # Convert figure to data array. 

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

335 

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 

340 

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 ) 

348 

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 ) 

353 

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 ) 

358 

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

360 

361 

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

363 """PerTractPropertyMapAnalysisTask test case. 

364 

365 Notes 

366 ----- 

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

368 task. 

369 """ 

370 

371 def setUp(self): 

372 # Create a temporary directory to test in. 

373 self.testDir = makeTestTempDir(ROOT) 

374 

375 # Create a butler in the test directory. 

376 Butler.makeRepo(self.testDir) 

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

378 

379 # Make a dummy dataId. 

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

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

382 

383 # Configure the maps to be plotted. 

384 config = SurveyWidePropertyMapAnalysisConfig() 

385 

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

391 

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

397 

398 # Generate a list of dataset type names. 

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

400 

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) 

414 

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 

420 

421 for atool in self.config.atools: 

422 atool.finalize() 

423 

424 def tearDown(self): 

425 del self.data 

426 del self.config 

427 del self.plotInfo 

428 removeTestTempDir(self.testDir) 

429 del self.testDir 

430 

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" 

437 

438 # Check that the key is in the result. 

439 self.assertIn(key, result) 

440 

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

444 

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

449 

450 

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

452 pass 

453 

454 

455def setup_module(module): 

456 lsst.utils.tests.init() 

457 

458 

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