Coverage for python / lsst / analysis / tools / actions / plot / colorColorFitPlot.py: 11%
255 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-25 08:54 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-25 08:54 +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/>.
22from __future__ import annotations
24__all__ = ("ColorColorFitPlot",)
26from collections.abc import Mapping
27from typing import cast
29import matplotlib.patheffects as pathEffects
30import numpy as np
31import scipy.stats
32from matplotlib.figure import Figure
33from matplotlib.patches import Rectangle
34from scipy.ndimage import median_filter
36from lsst.pex.config import Field, ListField, RangeField
37from lsst.utils.plotting import make_figure, set_rubin_plotstyle
39from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector
40from ...math import nanMean, nanMedian, nanSigmaMad
41from ..keyedData.stellarLocusFit import perpDistance
42from .plotUtils import addPlotInfo, mkColormap
45class ColorColorFitPlot(PlotAction):
46 """Make a color-color plot and overplot a prefited line to the fit region.
48 This is mostly used for the stellar locus plots and also includes panels
49 that illustrate the goodness of the given fit.
50 """
52 xAxisLabel = Field[str](doc="Label to use for the x axis", optional=False)
53 yAxisLabel = Field[str](doc="Label to use for the y axis", optional=False)
54 magLabel = Field[str](doc="Label to use for the magnitudes used to color code by", optional=False)
56 plotTypes = ListField[str](
57 doc="Selection of types of objects to plot. Can take any combination of"
58 " stars, galaxies, unknown, mag, any.",
59 default=["stars"],
60 )
62 plotName = Field[str](doc="The name for the plot.", optional=False)
63 minPointsForFit = RangeField[int](
64 doc="Minimum number of valid objects to bother attempting a fit.",
65 default=5,
66 min=1,
67 deprecated="This field is no longer used. The value should go as an "
68 "entry to the paramsDict keyed as minObjectForFit. Will be removed "
69 "after v27.",
70 )
72 xLims = ListField[float](
73 doc="Minimum and maximum x-axis limit to force (provided as a list of [xMin, xMax]). "
74 "If `None`, limits will be computed and set based on the data.",
75 dtype=float,
76 default=None,
77 optional=True,
78 )
80 yLims = ListField[float](
81 doc="Minimum and maximum y-axis limit to force (provided as a list of [yMin, yMax]). "
82 "If `None`, limits will be computed and set based on the data.",
83 dtype=float,
84 default=None,
85 optional=True,
86 )
88 doPlotRedBlueHists = Field[bool](
89 doc="Plot distance from fit histograms separated into blue and red star subsamples?",
90 default=False,
91 optional=True,
92 )
94 doPlotDistVsColor = Field[bool](
95 doc="Plot distance from fit as a function of color in lower right panel?",
96 default=True,
97 optional=True,
98 )
100 publicationStyle = Field[bool](
101 doc="Use a publication-quality plot style.",
102 default=False,
103 )
105 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
106 base: list[tuple[str, type[Vector] | type[Scalar]]] = []
107 base.append(("x", Vector))
108 base.append(("y", Vector))
109 base.append(("mag", Vector))
110 base.append(("approxMagDepth", Scalar))
111 base.append((f"{self.plotName}_sigmaMAD", Scalar))
112 base.append((f"{self.plotName}_median", Scalar))
113 base.append(("mODR", Scalar))
114 base.append(("bODR", Scalar))
115 base.append(("bPerpMin", Scalar))
116 base.append(("bPerpMax", Scalar))
117 base.append(("mPerp", Scalar))
119 return base
121 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
122 self._validateInput(data, **kwargs)
123 return self.makePlot(data, **kwargs)
125 def _validateInput(self, data: KeyedData, **kwargs) -> None:
126 """NOTE currently can only check that something is not a scalar, not
127 check that data is consistent with Vector
128 """
129 needed = self.getInputSchema(**kwargs)
130 if remainder := {key.format(**kwargs) for key, _ in needed} - {
131 key.format(**kwargs) for key in data.keys()
132 }:
133 raise ValueError(f"Task needs keys {remainder} but they were not in input")
134 for name, typ in needed:
135 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
136 if isScalar and typ != Scalar:
137 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}")
139 def makePlot(
140 self,
141 data: KeyedData,
142 plotInfo: Mapping[str, str],
143 **kwargs,
144 ) -> Figure:
145 """Make stellar locus plots using pre fitted values.
147 Parameters
148 ----------
149 data : `KeyedData`
150 The data to plot the points from, for more information
151 please see the notes section.
152 plotInfo : `dict`
153 A dictionary of information about the data being plotted
154 with keys:
156 * ``"run"``
157 The output run for the plots (`str`).
158 * ``"skymap"``
159 The type of skymap used for the data (`str`).
160 * ``"filter"``
161 The filter used for this data (`str`).
162 * ``"tract"``
163 The tract that the data comes from (`str`).
165 Returns
166 -------
167 fig : `matplotlib.figure.Figure`
168 The resulting figure.
170 Notes
171 -----
172 The axis labels are given by `self.xAxisLabel` and `self.yAxisLabel`.
173 The perpendicular distance of the points to the fit line is given in a
174 histogram in the second panel.
176 For the code to work it expects various quantities to be present in
177 the `data` that it is given.
179 The quantities that are expected to be present are:
181 * Statistics that are shown on the plot or used by the plotting code:
182 * ``approxMagDepth``
183 The approximate magnitude corresponding to the SN cut used.
184 * ``f"{self.plotName}_sigmaMAD"``
185 The sigma mad of the distances to the line fit.
186 * ``f"{self.plotName or ''}_median"``
187 The median of the distances to the line fit.
189 * Parameters from the fitting code that are illustrated on the plot:
190 * ``"bFixed"``
191 The fixed intercept to fall back on.
192 * ``"mFixed"``
193 The fixed gradient to fall back on.
194 * ``"bODR"``
195 The intercept calculated by the final orthogonal distance
196 regression fitting.
197 * ``"mODR"``
198 The gradient calculated by the final orthogonal distance
199 regression fitting.
200 * ``"xMin`"``
201 The x minimum of the box used in the fit.
202 * ``"xMax"``
203 The x maximum of the box used in the fit.
204 * ``"yMin"``
205 The y minimum of the box used in the fit.
206 * ``"yMax"``
207 The y maximum of the box used in the fit.
208 * ``"mPerp"``
209 The gradient of the line perpendicular to the line from
210 the second ODR fit.
211 * ``"bPerpMin"``
212 The intercept of the perpendicular line that goes through
213 xMin.
214 * ``"bPerpMax"``
215 The intercept of the perpendicular line that goes through
216 xMax.
217 * ``"goodPoints"``
218 The points that passed the initial set of cuts (typically
219 in fluxType S/N, extendedness, magnitude, and isfinite).
220 * ``"fitPoints"``
221 The points use in the final fit.
223 * The main inputs to plot:
224 x, y, mag
226 Examples
227 --------
228 An example of the plot produced from this code is here:
230 .. image:: /_static/analysis_tools/stellarLocusExample.png
232 For a detailed example of how to make a plot from the command line
233 please see the
234 :ref:`getting started guide<analysis-tools-getting-started>`.
235 """
236 set_rubin_plotstyle()
237 paramDict = data.pop("paramDict")
238 # Points to use for the fit.
239 fitPoints = data.pop("fitPoints")
240 # Points with finite values for x, y, and mag.
241 goodPoints = data.pop("goodPoints")
243 # TODO: Make a no data fig function and use here.
244 if sum(fitPoints) < paramDict["minObjectForFit"]:
245 fig = make_figure(dpi=120)
246 ax = fig.add_axes([0.12, 0.25, 0.43, 0.62])
247 ax.tick_params(labelsize=7)
248 noDataText = (
249 "Number of objects after cuts ({})\nis less than the minimum required\nby "
250 "paramDict[minObjectForFit] ({})".format(sum(fitPoints), int(paramDict["minObjectForFit"]))
251 )
252 fig.text(0.5, 0.5, noDataText, ha="center", va="center", fontsize=8)
253 fig = addPlotInfo(fig, plotInfo)
254 return fig
256 # Define new colormaps.
257 newBlues = mkColormap(["#3D5195"])
258 newGrays = mkColormap(["#CFCFCF", "#595959"])
260 # Make a figure with three panels.
261 fig = make_figure(dpi=300)
262 ax = fig.add_axes([0.12, 0.25, 0.43, 0.62])
263 if self.doPlotDistVsColor:
264 axLowerRight = fig.add_axes([0.65, 0.11, 0.26, 0.34])
265 else:
266 axLowerRight = fig.add_axes([0.65, 0.11, 0.3, 0.34])
267 axHist = fig.add_axes([0.65, 0.55, 0.3, 0.32])
269 xs = cast(Vector, data["x"])
270 ys = cast(Vector, data["y"])
271 mags = cast(Vector, data["mag"])
273 # Plot the initial fit box.
274 (initialBox,) = ax.plot(
275 [paramDict["xMin"], paramDict["xMax"], paramDict["xMax"], paramDict["xMin"], paramDict["xMin"]],
276 [paramDict["yMin"], paramDict["yMin"], paramDict["yMax"], paramDict["yMax"], paramDict["yMin"]],
277 "k",
278 alpha=0.3,
279 label="Initial selection",
280 )
282 if not self.publicationStyle:
283 # Add some useful information to the plot.
284 bbox = dict(alpha=0.9, facecolor="white", edgecolor="none")
285 infoText = f"N Total: {sum(goodPoints)}\nN Used: {sum(fitPoints)}"
286 ax.text(0.04, 0.97, infoText, color="k", transform=ax.transAxes, fontsize=7, bbox=bbox, va="top")
288 # Calculate the point density for the Used and NotUsed subsamples.
289 xyUsed = np.vstack([xs[fitPoints], ys[fitPoints]])
290 xyNotUsed = np.vstack([xs[~fitPoints & goodPoints], ys[~fitPoints & goodPoints]])
292 # Try using a Gaussian KDE to get color bars showing number density. If
293 # there are not enough points for KDE, use a constant color.
294 zUsed = np.ones(np.sum(fitPoints))
295 if len(xyUsed.ravel()) >= 2:
296 try:
297 zUsed = scipy.stats.gaussian_kde(xyUsed)(xyUsed)
298 except np.linalg.LinAlgError:
299 pass
301 zNotUsed = np.ones(np.sum(~fitPoints & goodPoints))
302 if len(xyNotUsed.ravel()) >= 2:
303 try:
304 zNotUsed = scipy.stats.gaussian_kde(xyNotUsed)(xyNotUsed)
305 except np.linalg.LinAlgError:
306 pass
308 notUsedScatter = ax.scatter(
309 xs[~fitPoints & goodPoints], ys[~fitPoints & goodPoints], c=zNotUsed, cmap=newGrays, s=0.3
310 )
311 fitScatter = ax.scatter(
312 xs[fitPoints], ys[fitPoints], c=zUsed, cmap=newBlues, s=0.3, label="Used for Fit"
313 )
315 # Add colorbars.
316 cbAx = fig.add_axes([0.12, 0.07, 0.43, 0.04])
317 fig.colorbar(fitScatter, cax=cbAx, orientation="horizontal")
318 cbKwargs = {
319 "color": "k",
320 "rotation": "horizontal",
321 "ha": "center",
322 "va": "center",
323 "fontsize": 7,
324 }
325 cbText = cbAx.text(
326 0.5,
327 0.5,
328 "Number Density (used in fit)",
329 transform=cbAx.transAxes,
330 **cbKwargs,
331 )
332 cbText.set_path_effects([pathEffects.Stroke(linewidth=1.5, foreground="w"), pathEffects.Normal()])
333 cbAx.set_xticks([np.min(zUsed), np.max(zUsed)], labels=["Less", "More"], fontsize=7)
334 cbAxNotUsed = fig.add_axes([0.12, 0.11, 0.43, 0.04])
335 fig.colorbar(notUsedScatter, cax=cbAxNotUsed, orientation="horizontal")
336 cbText = cbAxNotUsed.text(
337 0.5,
338 0.5,
339 "Number Density (not used in fit)",
340 transform=cbAxNotUsed.transAxes,
341 **cbKwargs,
342 )
343 cbText.set_path_effects([pathEffects.Stroke(linewidth=1.5, foreground="w"), pathEffects.Normal()])
344 cbAxNotUsed.set_xticks([])
346 ax.set_xlabel(self.xAxisLabel)
347 ax.set_ylabel(self.yAxisLabel)
348 ax.tick_params(labelsize=7)
350 # Set axis limits from configs if set, otherwise based on the data.
351 if self.xLims is not None:
352 ax.set_xlim(self.xLims[0], self.xLims[1])
353 else:
354 percsX = np.nanpercentile(xs[goodPoints], [0.5, 99.5])
355 x5 = (percsX[1] - percsX[0]) / 5
356 ax.set_xlim(percsX[0] - x5, percsX[1] + x5)
357 if self.yLims is not None:
358 ax.set_ylim(self.yLims[0], self.yLims[1])
359 else:
360 percsY = np.nanpercentile(ys[goodPoints], [0.5, 99.5])
361 y5 = (percsY[1] - percsY[0]) / 5
362 ax.set_ylim(percsY[0] - y5, percsY[1] + y5)
364 # Plot the fit lines.
365 if np.fabs(paramDict["mFixed"]) > 1:
366 ysFitLineFixed = np.array([paramDict["yMin"], paramDict["yMax"]])
367 xsFitLineFixed = (ysFitLineFixed - paramDict["bFixed"]) / paramDict["mFixed"]
368 ysFitLine = np.array([paramDict["yMin"], paramDict["yMax"]])
369 xsFitLine = (ysFitLine - data["bODR"]) / data["mODR"]
371 else:
372 xsFitLineFixed = np.array([paramDict["xMin"], paramDict["xMax"]])
373 ysFitLineFixed = paramDict["mFixed"] * xsFitLineFixed + paramDict["bFixed"]
374 xsFitLine = np.array([paramDict["xMin"], paramDict["xMax"]])
375 ysFitLine = np.array(
376 [
377 data["mODR"] * xsFitLine[0] + data["bODR"],
378 data["mODR"] * xsFitLine[1] + data["bODR"],
379 ]
380 )
382 ax.plot(xsFitLineFixed, ysFitLineFixed, "w", lw=1.5)
383 (lineFixed,) = ax.plot(xsFitLineFixed, ysFitLineFixed, "#C85200", lw=1, ls="--", label="Fixed")
384 ax.plot(xsFitLine, ysFitLine, "w", lw=1.5)
385 (lineOdrFit,) = ax.plot(xsFitLine, ysFitLine, "k", lw=1, ls="--", label="ODR Fit")
386 ax.legend(
387 handles=[initialBox, lineFixed, lineOdrFit], handlelength=1.5, fontsize=6, loc="lower right"
388 )
390 # Calculate the distances (in mmag) to the line for the data used in
391 # the fit. Two points are needed to characterize the lines we want
392 # to get the distances to.
393 p1 = np.array([xsFitLine[0], ysFitLine[0]])
394 p2 = np.array([xsFitLine[1], ysFitLine[1]])
396 p1Fixed = np.array([xsFitLineFixed[0], ysFitLineFixed[0]])
397 p2Fixed = np.array([xsFitLineFixed[1], ysFitLineFixed[1]])
399 # Convert to millimags.
400 statsUnitStr = "mmag"
401 distsFixed = np.array(perpDistance(p1Fixed, p2Fixed, zip(xs[fitPoints], ys[fitPoints]))) * 1000
402 dists = np.array(perpDistance(p1, p2, zip(xs[fitPoints], ys[fitPoints]))) * 1000
403 maxDist = np.abs(np.nanmax(dists)) / 1000 # These will be used to set the fit boundary line limits.
404 minDist = np.abs(np.nanmin(dists)) / 1000
405 # Now we have the information for the perpendicular line we can use it
406 # to calculate the points at the ends of the perpendicular lines that
407 # intersect at the box edges.
408 if np.fabs(paramDict["mFixed"]) > 1:
409 xMid = (paramDict["yMin"] - data["bODR"]) / data["mODR"]
410 xsFit = np.array([xMid - max(0.2, maxDist), xMid, xMid + max(0.2, minDist)])
411 ysFit = data["mPerp"] * xsFit + data["bPerpMin"]
412 else:
413 xsFit = np.array(
414 [
415 paramDict["xMin"] - max(0.2, np.fabs(paramDict["mFixed"]) * maxDist),
416 paramDict["xMin"],
417 paramDict["xMin"] + max(0.2, np.fabs(paramDict["mFixed"]) * minDist),
418 ]
419 )
420 ysFit = xsFit * data["mPerp"] + data["bPerpMin"]
421 ax.plot(xsFit, ysFit, "k--", alpha=0.7, lw=1)
423 if np.fabs(paramDict["mFixed"]) > 1:
424 xMid = (paramDict["yMax"] - data["bODR"]) / data["mODR"]
425 xsFit = np.array([xMid - max(0.2, maxDist), xMid, xMid + max(0.2, minDist)])
426 ysFit = data["mPerp"] * xsFit + data["bPerpMax"]
427 else:
428 xsFit = np.array(
429 [
430 paramDict["xMax"] - max(0.2, np.fabs(paramDict["mFixed"]) * maxDist),
431 paramDict["xMax"],
432 paramDict["xMax"] + max(0.2, np.fabs(paramDict["mFixed"]) * minDist),
433 ]
434 )
435 ysFit = xsFit * data["mPerp"] + data["bPerpMax"]
436 ax.plot(xsFit, ysFit, "k--", alpha=0.7, lw=1)
438 # Compute statistics for fit.
439 medDists = nanMedian(dists)
440 madDists = nanSigmaMad(dists)
441 meanDists = nanMean(dists)
442 rmsDists = np.sqrt(np.mean(np.array(dists) ** 2))
444 xMid = paramDict["xMin"] + 0.5 * (paramDict["xMax"] - paramDict["xMin"])
445 if self.doPlotRedBlueHists:
446 blueStars = (xs[fitPoints] < xMid) & (xs[fitPoints] >= paramDict["xMin"])
447 blueDists = dists[blueStars]
448 blueMedDists = nanMedian(blueDists)
449 redStars = (xs[fitPoints] >= xMid) & (xs[fitPoints] <= paramDict["xMax"])
450 redDists = dists[redStars]
451 redMedDists = nanMedian(redDists)
453 if self.doPlotRedBlueHists:
454 blueStars = (xs[fitPoints] < xMid) & (xs[fitPoints] >= paramDict["xMin"])
455 blueDists = dists[blueStars]
456 blueMedDists = nanMedian(blueDists)
457 redStars = (xs[fitPoints] >= xMid) & (xs[fitPoints] <= paramDict["xMax"])
458 redDists = dists[redStars]
459 redMedDists = nanMedian(redDists)
461 # Add a histogram.
462 axHist.set_ylabel("Number", fontsize=7)
463 axHist.set_xlabel(f"Distance to Line Fit ({statsUnitStr})", fontsize=7)
464 axHist.tick_params(labelsize=7)
465 nSigToPlot = 3.5
466 axHist.set_xlim(meanDists - nSigToPlot * madDists, meanDists + nSigToPlot * madDists)
467 lineMedian = axHist.axvline(
468 medDists, color="k", lw=1, alpha=0.5, label=f"Median: {medDists:0.2f} {statsUnitStr}"
469 )
470 lineMad = axHist.axvline(
471 medDists + madDists,
472 color="k",
473 ls="--",
474 lw=1,
475 alpha=0.5,
476 label=r"$\sigma_{MAD}$" + f": {madDists:0.2f} {statsUnitStr}",
477 )
478 axHist.axvline(medDists - madDists, color="k", ls="--", lw=1, alpha=0.5)
479 lineRms = axHist.axvline(
480 meanDists + rmsDists,
481 color="k",
482 ls=":",
483 lw=1,
484 alpha=0.3,
485 label=f"RMS: {rmsDists:0.2f} {statsUnitStr}",
486 )
487 axHist.axvline(meanDists - rmsDists, color="k", ls=":", lw=1, alpha=0.3)
488 if self.doPlotRedBlueHists:
489 lineBlueMedian = axHist.axvline(
490 blueMedDists,
491 color="blue",
492 ls=":",
493 lw=1.0,
494 alpha=0.5,
495 label=f"blueMed: {blueMedDists:0.2f}",
496 )
497 lineRedMedian = axHist.axvline(
498 redMedDists,
499 color="red",
500 ls=":",
501 lw=1.0,
502 alpha=0.5,
503 label=f"redMed: {redMedDists:0.2f}",
504 )
505 linesForLegend = [lineMedian, lineMad, lineRms, lineBlueMedian, lineRedMedian]
506 else:
507 linesForLegend = [lineMedian, lineMad, lineRms]
508 fig.legend(
509 handles=linesForLegend,
510 handlelength=1.0,
511 fontsize=6,
512 loc="lower right",
513 bbox_to_anchor=(0.955, 0.89),
514 bbox_transform=fig.transFigure,
515 ncol=2,
516 )
518 axHist.hist(dists, bins=100, histtype="stepfilled", label="ODR Fit", color="k", ec="k", alpha=0.3)
519 axHist.hist(distsFixed, bins=100, histtype="step", label="Fixed", color="#C85200", alpha=1.0)
520 if self.doPlotRedBlueHists:
521 axHist.hist(blueDists, bins=100, histtype="stepfilled", color="blue", ec="blue", alpha=0.3)
522 axHist.hist(redDists, bins=100, histtype="stepfilled", color="red", ec="red", alpha=0.3)
524 handles = [Rectangle((0, 0), 1, 1, color="k", alpha=0.4)]
525 handles.append(Rectangle((0, 0), 1, 1, color="none", ec="#C85200", alpha=1.0))
526 labels = ["ODR Fit", "Fixed"]
527 if self.doPlotRedBlueHists:
528 handles.append(Rectangle((0, 0), 1, 1, color="blue", alpha=0.3))
529 handles.append(Rectangle((0, 0), 1, 1, color="red", alpha=0.3))
530 labels = ["ODR Fit", "Blue Stars", "Red Stars", "Fixed"]
531 axHist.legend(handles, labels, fontsize=5, loc="upper right")
533 if self.doPlotDistVsColor:
534 axLowerRight.axhline(0.0, color="k", ls="-", lw=0.8, zorder=-1)
535 # Compute and plot a running median of dists vs. color.
536 if np.fabs(data["mODR"]) > 1.0:
537 xRun = ys[fitPoints].copy()
538 axLowerRight.set_xlabel(self.yAxisLabel, fontsize=7)
539 else:
540 xRun = xs[fitPoints].copy()
541 axLowerRight.set_xlabel(self.xAxisLabel, fontsize=7)
542 lowerRightPlot = axLowerRight.scatter(xRun, dists, c=mags[fitPoints], cmap=newBlues, s=0.2)
543 yRun = dists.copy()
544 xySorted = zip(xRun, yRun)
545 xySorted = sorted(xySorted)
546 xSorted = [x for x, y in xySorted]
547 ySorted = [y for x, y in xySorted]
548 nCumulate = int(max(3, len(xRun) // 10))
549 yRunMedian = median_filter(ySorted, size=nCumulate)
550 axLowerRight.plot(xSorted, yRunMedian, "w", lw=1.8)
551 axLowerRight.plot(xSorted, yRunMedian, c="#595959", ls="-", lw=1.1, label="Running Median")
552 axLowerRight.set_ylim(-2.5 * madDists, 2.5 * madDists)
553 axLowerRight.set_ylabel(f"Distance to Line Fit ({statsUnitStr})", fontsize=7)
554 axLowerRight.legend(fontsize=4, loc="upper right", handlelength=1.0)
555 # Add colorbars.
556 cbAx = fig.add_axes([0.915, 0.11, 0.014, 0.34])
557 fig.colorbar(lowerRightPlot, cax=cbAx, orientation="vertical")
558 cbKwargs = {
559 "color": "k",
560 "rotation": "vertical",
561 "ha": "center",
562 "va": "center",
563 "fontsize": 4,
564 }
565 cbText = cbAx.text(
566 0.5,
567 0.5,
568 self.magLabel,
569 transform=cbAx.transAxes,
570 **cbKwargs,
571 )
572 cbText.set_path_effects([pathEffects.Stroke(linewidth=1.2, foreground="w"), pathEffects.Normal()])
573 cbAx.tick_params(length=2, labelsize=3.5)
574 else:
575 # Add a contour plot showing the magnitude dependance of the
576 # distance to the fit.
577 axLowerRight.invert_yaxis()
578 axLowerRight.axvline(0.0, color="k", ls="--", zorder=-1)
579 percsDists = np.nanpercentile(dists, [4, 96])
580 minXs = -1 * np.min(np.fabs(percsDists))
581 maxXs = np.min(np.fabs(percsDists))
582 plotPoints = (dists < maxXs) & (dists > minXs)
583 xsContour = np.array(dists)[plotPoints]
584 ysContour = cast(Vector, cast(Vector, mags)[cast(Vector, fitPoints)])[cast(Vector, plotPoints)]
585 H, xEdges, yEdges = np.histogram2d(xsContour, ysContour, bins=(11, 11))
586 xBinWidth = xEdges[1] - xEdges[0]
587 yBinWidth = yEdges[1] - yEdges[0]
588 axLowerRight.contour(
589 xEdges[:-1] + xBinWidth / 2, yEdges[:-1] + yBinWidth / 2, H.T, levels=7, cmap=newBlues
590 )
591 axLowerRight.set_xlabel(f"Distance to Line Fit ({statsUnitStr})", fontsize=8)
592 axLowerRight.set_ylabel(self.magLabel, fontsize=8)
593 axLowerRight.set_xlim(meanDists - nSigToPlot * madDists, meanDists + nSigToPlot * madDists)
594 axLowerRight.tick_params(labelsize=6)
596 # This is here because matplotlib occasionally decides
597 # that there needs to be 10^15 minor ticks.
598 # This is probably unneeded and may make the plot look
599 # rather busy if it ever finds enough memory to render.
600 # If this ever becomes a problem it can be looked at in
601 # the future.
602 for ax in fig.get_axes():
603 ax.minorticks_off()
605 fig.canvas.draw()
606 if not self.publicationStyle:
607 fig = addPlotInfo(fig, plotInfo)
609 return fig