Coverage for python/lsst/analysis/tools/actions/plot/wholeSkyPlot.py: 16%

154 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-14 02:48 -0700

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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("WholeSkyPlot",) 

25 

26from typing import Mapping, Optional 

27 

28import matplotlib.patheffects as pathEffects 

29import matplotlib.pyplot as plt 

30import numpy as np 

31from lsst.pex.config import ChoiceField, Field, ListField 

32from matplotlib.collections import PatchCollection 

33from matplotlib.colors import CenteredNorm 

34from matplotlib.figure import Figure 

35from matplotlib.patches import Patch, Polygon 

36 

37from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector 

38from ...math import nanSigmaMad 

39from ...utils import getTractCorners 

40from .plotUtils import addPlotInfo, mkColormap 

41 

42 

43class WholeSkyPlot(PlotAction): 

44 """Plots the on sky distribution of a parameter. 

45 

46 Plots the values of the parameter given for the z axis 

47 according to the positions given for x and y. Optimised 

48 for use with RA and Dec. Also calculates some basic 

49 statistics and includes those on the plot. 

50 

51 The default axes limits and figure size were chosen to plot HSC PDR2. 

52 """ 

53 

54 xAxisLabel = Field[str](doc="Label to use for the x axis.", default="RA (degrees)") 

55 yAxisLabel = Field[str](doc="Label to use for the y axis.", default="Dec (degrees)") 

56 zAxisLabel = Field[str](doc="Label to use for the z axis.", optional=False) 

57 autoAxesLimits = Field[bool](doc="Find axes limits automatically.", default=True) 

58 xLimits = ListField[float](doc="Plotting limits for the x axis.", default=[-5.0, 365.0]) 

59 yLimits = ListField[float](doc="Plotting limits for the y axis.", default=[-10.0, 60.0]) 

60 figureSize = ListField[float](doc="Size of the figure.", default=[9.0, 3.5]) 

61 colorBarRange = Field[float]( 

62 doc="The multiplier for the color bar range. The max/min range values are: median +/- N * sigmaMad" 

63 ", where N is this config value.", 

64 default=3.0, 

65 ) 

66 colorMapType = ChoiceField[str]( 

67 doc="Type of color map to use for the color bar. Options: sequential, divergent, userDefined.", 

68 allowed={cmType: cmType for cmType in ("sequential", "divergent")}, 

69 default="divergent", 

70 ) 

71 colorMap = ListField[str]( 

72 doc="List of hexidecimal colors for a user-defined color map.", 

73 optional=True, 

74 ) 

75 

76 def getInputSchema(self, **kwargs) -> KeyedDataSchema: 

77 base = [] 

78 base.append(("z", Vector)) 

79 base.append(("tract", Vector)) 

80 return base 

81 

82 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure: 

83 self._validateInput(data, **kwargs) 

84 return self.makePlot(data, **kwargs) 

85 

86 def _validateInput(self, data: KeyedData, **kwargs) -> None: 

87 """NOTE currently can only check that something is not a Scalar, not 

88 check that the data is consistent with Vector 

89 """ 

90 needed = self.getInputSchema(**kwargs) 

91 if remainder := {key.format(**kwargs) for key, _ in needed} - { 

92 key.format(**kwargs) for key in data.keys() 

93 }: 

94 raise ValueError(f"Task needs keys {remainder} but they were not found in input") 

95 for name, typ in needed: 

96 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar) 

97 if isScalar and typ != Scalar: 

98 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}") 

99 

100 def _getAxesLimits(self, xs: list, ys: list) -> tuple(list, list): 

101 """Get the x and y axes limits in degrees. 

102 

103 Parameters 

104 ---------- 

105 xs : `list` 

106 X coordinates for the tracts to plot. 

107 ys : `list` 

108 Y coordinates for the tracts to plot. 

109 

110 Returns 

111 ------- 

112 xlim : `list` 

113 Minimun and maximum x axis values. 

114 ylim : `list` 

115 Minimun and maximum y axis values. 

116 """ 

117 

118 # Add some blank space on the edges of the plot. 

119 xlim = [np.nanmin(xs) - 5, np.nanmax(xs) + 5] 

120 ylim = [np.nanmin(ys) - 5, np.nanmax(ys) + 5] 

121 

122 # Limit to only show real RA/Dec values. 

123 if xlim[0] < 0.0: 

124 xlim[0] = 0.0 

125 if xlim[1] > 360.0: 

126 xlim[1] = 360.0 

127 if ylim[0] < -90.0: 

128 ylim[0] = -90.0 

129 if ylim[1] > 90.0: 

130 ylim[1] = 90.0 

131 

132 return (xlim, ylim) 

133 

134 def _getMaxOutlierVals(self, multiplier: float, tracts: list, values: list, outlierInds: list) -> str: 

135 """Get the 5 largest outlier values in a string. 

136 

137 Parameters 

138 ---------- 

139 multiplier : `float` 

140 Select values whose absolute value is > multiplier * sigmaMAD. 

141 tracts : `list` 

142 All the tracts. 

143 values : `list` 

144 All the metric values. 

145 outlierInds : `list` 

146 Indicies of outlier values. 

147 

148 Returns 

149 ------- 

150 text : `str` 

151 A string containing the 5 tracts with the largest outlier values. 

152 """ 

153 text = f"Tracts with |value| > {multiplier}" + r"$\sigma_{MAD}$" + ": " 

154 if len(outlierInds) > 0: 

155 outlierValues = np.array(values)[outlierInds] 

156 outlierTracts = np.array(tracts)[outlierInds] 

157 # Sort values in descending (-) absolute value order discounting 

158 # NaNs. 

159 maxInds = np.argsort(-np.abs(outlierValues)) 

160 # Show up to five values on the plot. 

161 for ind in maxInds[:5]: 

162 val = outlierValues[ind] 

163 tract = outlierTracts[ind] 

164 text += f"{tract}, {val:.3}; " 

165 # Remove the final trailing comma and whitespace. 

166 text = text[:-2] 

167 else: 

168 text += "None" 

169 

170 return text 

171 

172 def makePlot( 

173 self, 

174 data: KeyedData, 

175 plotInfo: Optional[Mapping[str, str]] = None, 

176 **kwargs, 

177 ) -> Figure: 

178 """Make a WholeSkyPlot of the given data. 

179 

180 Parameters 

181 ---------- 

182 data : `KeyedData` 

183 The catalog to plot the points from. 

184 plotInfo : `dict` 

185 A dictionary of information about the data being plotted with keys: 

186 

187 ``"run"`` 

188 The output run for the plots (`str`). 

189 ``"skymap"`` 

190 The type of skymap used for the data (`str`). 

191 ``"filter"`` 

192 The filter used for this data (`str`). 

193 ``"tract"`` 

194 The tract that the data comes from (`str`). 

195 

196 Returns 

197 ------- 

198 `pipeBase.Struct` containing: 

199 skyPlot : `matplotlib.figure.Figure` 

200 The resulting figure. 

201 

202 

203 Examples 

204 -------- 

205 An example of the plot produced from this code is here: 

206 

207 .. image:: /_static/analysis_tools/wholeSkyPlotExample.png 

208 

209 For a detailed example of how to make a plot from the command line 

210 please see the 

211 :ref:`getting started guide<analysis-tools-getting-started>`. 

212 """ 

213 skymap = kwargs["skymap"] 

214 if plotInfo is None: 

215 plotInfo = {} 

216 

217 # Prevent Bands in the plot info showing a list of bands. 

218 # If bands is a list, it implies that parameterizedBand=False, 

219 # and that the metric is not band-specific. 

220 if "bands" in plotInfo: 

221 if isinstance(plotInfo["bands"], list): 

222 plotInfo["bands"] = "N/A" 

223 

224 colorMap = self.colorMap 

225 match self.colorMapType: 

226 case "sequential": 

227 if colorMap is None: 

228 colorMap = mkColormap(["#F5F5F5", "#5AB4AC", "#284D48"]) 

229 norm = None 

230 case "divergent": 

231 if colorMap is None: 

232 colorMap = mkColormap(["#9A6E3A", "#C6A267", "#A9A9A9", "#4F938B", "#2C665A"]) 

233 norm = CenteredNorm() 

234 

235 # Create patches using the corners of each tract. 

236 patches = [] 

237 colBarVals = [] 

238 tracts = [] 

239 ras = [] 

240 decs = [] 

241 for i, tract in enumerate(data["tract"]): 

242 corners = getTractCorners(skymap, tract) 

243 patches.append(Polygon(corners, closed=True)) 

244 colBarVals.append(data["z"][i]) 

245 tracts.append(tract) 

246 ras.append(corners[0][0]) 

247 decs.append(corners[0][1]) 

248 

249 # Setup figure. 

250 fig, ax = plt.subplots(1, 1, figsize=self.figureSize, dpi=500) 

251 if self.autoAxesLimits: 

252 xlim, ylim = self._getAxesLimits(ras, decs) 

253 else: 

254 xlim, ylim = self.xLimits, self.yLimits 

255 ax.set_xlim(xlim) 

256 ax.set_ylim(ylim) 

257 ax.set_xlabel(self.xAxisLabel) 

258 ax.set_ylabel(self.yAxisLabel) 

259 ax.invert_xaxis() 

260 

261 # Add colored patches showing tract metric values. 

262 patchCollection = PatchCollection(patches, cmap=colorMap, norm=norm) 

263 ax.add_collection(patchCollection) 

264 

265 # Define color bar range. 

266 med = np.nanmedian(colBarVals) 

267 sigmaMad = nanSigmaMad(colBarVals) 

268 vmin = med - self.colorBarRange * sigmaMad 

269 vmax = med + self.colorBarRange * sigmaMad 

270 

271 # Note tracts with metrics outside (vmin, vmax) as outliers. 

272 outlierInds = np.where((colBarVals < vmin) | (colBarVals > vmax))[0] 

273 

274 # Initialize legend handles. 

275 handles = [] 

276 

277 # Plot the outlier patches. 

278 outlierPatches = [] 

279 if len(outlierInds) > 0: 

280 for ind in outlierInds: 

281 outlierPatches.append(patches[ind]) 

282 outlierPatchCollection = PatchCollection( 

283 outlierPatches, 

284 cmap=colorMap, 

285 norm=norm, 

286 facecolors="none", 

287 edgecolors="k", 

288 linewidths=0.5, 

289 zorder=100, 

290 ) 

291 ax.add_collection(outlierPatchCollection) 

292 # Add legend information. 

293 outlierPatch = Patch( 

294 facecolor="none", 

295 edgecolor="k", 

296 linewidth=0.5, 

297 label="Outlier", 

298 ) 

299 handles.append(outlierPatch) 

300 

301 # Plot tracts with NaN metric values. 

302 nanInds = np.where(~np.isfinite(colBarVals))[0] 

303 nanPatches = [] 

304 if len(nanInds) > 0: 

305 for ind in nanInds: 

306 nanPatches.append(patches[ind]) 

307 nanPatchCollection = PatchCollection( 

308 nanPatches, 

309 cmap=None, 

310 norm=norm, 

311 facecolors="white", 

312 edgecolors="grey", 

313 linestyles="dotted", 

314 linewidths=0.5, 

315 zorder=10, 

316 ) 

317 ax.add_collection(nanPatchCollection) 

318 # Add legend information. 

319 nanPatch = Patch( 

320 facecolor="white", 

321 edgecolor="grey", 

322 linestyle="dotted", 

323 linewidth=0.5, 

324 label="NaN", 

325 ) 

326 handles.append(nanPatch) 

327 

328 if len(handles) > 0: 

329 fig.legend(handles=handles) 

330 

331 # Add text boxes to show the number of tracts, number of NaNs, 

332 # median, sigma MAD, and the five largest outlier values. 

333 outlierText = self._getMaxOutlierVals(self.colorBarRange, tracts, colBarVals, outlierInds) 

334 # Make vertical text spacing readable for different figure sizes. 

335 multiplier = 3.5 / self.figureSize[1] 

336 verticalSpacing = 0.028 * multiplier 

337 fig.text( 

338 0.01, 

339 0.01 + 3 * verticalSpacing, 

340 f"Num tracts: {len(tracts)}", 

341 transform=fig.transFigure, 

342 fontsize=8, 

343 alpha=0.7, 

344 ) 

345 fig.text( 

346 0.01, 

347 0.01 + 2 * verticalSpacing, 

348 f"Num nans: {len(nanInds)}", 

349 transform=fig.transFigure, 

350 fontsize=8, 

351 alpha=0.7, 

352 ) 

353 fig.text( 

354 0.01, 

355 0.01 + verticalSpacing, 

356 f"Median: {med:.3f}; " + r"$\sigma_{MAD}$" + f": {sigmaMad:.3f}", 

357 transform=fig.transFigure, 

358 fontsize=8, 

359 alpha=0.7, 

360 ) 

361 fig.text(0.01, 0.01, outlierText, transform=fig.transFigure, fontsize=8, alpha=0.7) 

362 

363 # Truncate the color range to (vmin, vmax). 

364 colorBarVals = np.clip(np.array(colBarVals), vmin, vmax) 

365 patchCollection.set_array(colorBarVals) 

366 # Make the color bar with a metric label. 

367 cbar = plt.colorbar( 

368 patchCollection, 

369 ax=ax, 

370 shrink=0.7, 

371 extend="both", 

372 location="top", 

373 orientation="horizontal", 

374 ) 

375 cbarText = self.zAxisLabel.format_map(kwargs) 

376 if "zUnit" in data and data["zUnit"] != "": 

377 cbarText += f" ({data['zUnit']})" 

378 text = cbar.ax.text( 

379 0.5, 

380 0.5, 

381 cbarText, 

382 transform=cbar.ax.transAxes, 

383 ha="center", 

384 va="center", 

385 fontsize=10, 

386 zorder=100, 

387 ) 

388 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()]) 

389 

390 # Finalize plot appearance. 

391 ax.grid() 

392 ax.set_axisbelow(True) 

393 ax.set_aspect("equal") 

394 fig = plt.gcf() 

395 fig = addPlotInfo(fig, plotInfo) 

396 plt.subplots_adjust(left=0.08, right=0.97, top=0.8, bottom=0.17, wspace=0.35) 

397 

398 return fig