Coverage for python / lsst / analysis / tools / actions / plot / plotUtils.py: 10%
290 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-19 01:45 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-19 01:45 -0800
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/>.
21from __future__ import annotations
23__all__ = ("PanelConfig", "sortAllArrays")
25from collections.abc import Iterable, Mapping
26from typing import TYPE_CHECKING
28import esutil
29import matplotlib
30import numpy as np
31from matplotlib import cm, colors
32from matplotlib.collections import PatchCollection
33from matplotlib.patches import Rectangle
34from scipy.stats import binned_statistic_2d
36from lsst.geom import Box2D, SpherePoint, degrees
37from lsst.pex.config import Config, Field
39from ...math import nanMedian, nanSigmaMad
41if TYPE_CHECKING:
42 from matplotlib.figure import Figure
44null_formatter = matplotlib.ticker.NullFormatter()
47def generateSummaryStats(data, skymap, plotInfo):
48 """Generate a summary statistic in each patch or detector.
50 Parameters
51 ----------
52 data : `dict`
53 A dictionary of the data to be plotted.
54 skymap : `lsst.skymap.BaseSkyMap`
55 The skymap associated with the data.
56 plotInfo : `dict`
57 A dictionary of the plot information.
59 Returns
60 -------
61 patchInfoDict : `dict`
62 A dictionary of the patch information.
63 """
64 tractInfo = skymap.generateTract(plotInfo["tract"])
65 tractWcs = tractInfo.getWcs()
67 # For now also convert the gen 2 patchIds to gen 3
68 if "y" in data.keys():
69 yCol = "y"
70 elif "yStars" in data.keys():
71 yCol = "yStars"
72 elif "yGalaxies" in data.keys():
73 yCol = "yGalaxies"
74 elif "yUnknowns" in data.keys():
75 yCol = "yUnknowns"
77 patchInfoDict = {}
78 maxPatchNum = tractInfo.num_patches.x * tractInfo.num_patches.y
79 patches = np.arange(0, maxPatchNum, 1)
81 # Histogram (group) the patch values, and return an array of
82 # "reverse indices" which is a specially encoded array of where
83 # every patch is in the overall array.
84 if len(data["patch"]) == 0:
85 rev = np.full(maxPatchNum + 2, maxPatchNum + 2)
86 else:
87 _, rev = esutil.stat.histogram(data["patch"], min=0, max=maxPatchNum - 1, rev=True)
89 for patch in patches:
90 # Pull out the onPatch indices
91 onPatch = rev[rev[patch] : rev[patch + 1]]
93 if len(onPatch) == 0:
94 stat = np.nan
95 else:
96 stat = nanMedian(data[yCol][onPatch])
97 try:
98 patchTuple = (int(patch.split(",")[0]), int(patch.split(",")[-1]))
99 patchInfo = tractInfo.getPatchInfo(patchTuple)
100 gen3PatchId = tractInfo.getSequentialPatchIndex(patchInfo)
101 except AttributeError:
102 # For native gen 3 tables the patches don't need converting
103 # When we are no longer looking at the gen 2 -> gen 3
104 # converted repos we can tidy this up
105 gen3PatchId = patch
106 patchInfo = tractInfo.getPatchInfo(patch)
108 corners = Box2D(patchInfo.getInnerBBox()).getCorners()
109 skyCoords = tractWcs.pixelToSky(corners)
111 patchInfoDict[gen3PatchId] = (skyCoords, stat)
113 tractCorners = Box2D(tractInfo.getBBox()).getCorners()
114 skyCoords = tractWcs.pixelToSky(tractCorners)
115 patchInfoDict["tract"] = (skyCoords, np.nan)
117 return patchInfoDict
120def generateSummaryStatsVisit(cat, colName, visitSummaryTable):
121 """Generate a summary statistic in each patch or detector.
123 Parameters
124 ----------
125 cat : `pandas.core.frame.DataFrame`
126 A dataframe of the data to be plotted.
127 colName : `str`
128 The name of the column to be plotted.
129 visitSummaryTable : `pandas.core.frame.DataFrame`
130 A dataframe of the visit summary table.
132 Returns
133 -------
134 visitInfoDict : `dict`
135 A dictionary of the visit information.
136 """
137 visitInfoDict = {}
138 for ccd in cat.detector.unique():
139 if ccd is None:
140 continue
141 onCcd = cat["detector"] == ccd
142 stat = nanMedian(cat[colName].values[onCcd])
144 sumRow = visitSummaryTable["id"] == ccd
145 corners = zip(visitSummaryTable["raCorners"][sumRow][0], visitSummaryTable["decCorners"][sumRow][0])
146 cornersOut = []
147 for ra, dec in corners:
148 corner = SpherePoint(ra, dec, units=degrees)
149 cornersOut.append(corner)
151 visitInfoDict[ccd] = (cornersOut, stat)
153 return visitInfoDict
156# Inspired by matplotlib.testing.remove_ticks_and_titles
157def get_and_remove_axis_text(ax) -> tuple[list[str], list[np.ndarray]]:
158 """Remove text from an Axis and its children and return with line points.
160 Parameters
161 ----------
162 ax : `plt.Axis`
163 A matplotlib figure axis.
165 Returns
166 -------
167 texts : `List[str]`
168 A list of all text strings (title and axis/legend/tick labels).
169 line_xys : `List[numpy.ndarray]`
170 A list of all line ``_xy`` attributes (arrays of shape ``(N, 2)``).
171 """
172 line_xys = [line._xy for line in ax.lines]
173 texts = [text.get_text() for text in (ax.title, ax.xaxis.label, ax.yaxis.label)]
174 ax.set_title("")
175 ax.set_xlabel("")
176 ax.set_ylabel("")
178 try:
179 texts_legend = ax.get_legend().texts
180 texts.extend(text.get_text() for text in texts_legend)
181 for text in texts_legend:
182 text.set_alpha(0)
183 except AttributeError:
184 pass
186 for idx in range(len(ax.texts)):
187 texts.append(ax.texts[idx].get_text())
188 ax.texts[idx].set_text("")
190 ax.xaxis.set_major_formatter(null_formatter)
191 ax.xaxis.set_minor_formatter(null_formatter)
192 ax.yaxis.set_major_formatter(null_formatter)
193 ax.yaxis.set_minor_formatter(null_formatter)
194 try:
195 ax.zaxis.set_major_formatter(null_formatter)
196 ax.zaxis.set_minor_formatter(null_formatter)
197 except AttributeError:
198 pass
199 for child in ax.child_axes:
200 texts_child, lines_child = get_and_remove_axis_text(child)
201 texts.extend(texts_child)
203 return texts, line_xys
206def get_and_remove_figure_text(figure: Figure):
207 """Remove text from a Figure and its Axes and return with line points.
209 Parameters
210 ----------
211 figure : `matplotlib.pyplot.Figure`
212 A matplotlib figure.
214 Returns
215 -------
216 texts : `List[str]`
217 A list of all text strings (title and axis/legend/tick labels).
218 line_xys : `List[numpy.ndarray]`, (N, 2)
219 A list of all line ``_xy`` attributes (arrays of shape ``(N, 2)``).
220 """
221 texts = [str(figure._suptitle)]
222 lines = []
223 figure.suptitle("")
225 texts.extend(text.get_text() for text in figure.texts)
226 figure.texts = []
228 for ax in figure.get_axes():
229 texts_ax, lines_ax = get_and_remove_axis_text(ax)
230 texts.extend(texts_ax)
231 lines.extend(lines_ax)
233 return texts, lines
236def parsePlotInfo(plotInfo: Mapping[str, str]) -> str:
237 """Extract information from the plotInfo dictionary and parses it into
238 a meaningful string that can be added to a figure.
240 Parameters
241 ----------
242 plotInfo : `dict`[`str`, `str`]
243 A plotInfo dictionary containing useful information to
244 be included on a figure.
246 Returns
247 -------
248 infoText : `str`
249 A string containing the plotInfo information, parsed in such a
250 way that it can be included on a figure.
251 """
252 photocalibDataset = "None"
253 astroDataset = "None"
255 run = plotInfo["run"]
256 datasetsUsed = f"\nPhotoCalib: {photocalibDataset}, Astrometry: {astroDataset}"
257 tableType = f"\nTable: {plotInfo['tableName']}"
259 dataIdText = ""
260 if "tract" in plotInfo.keys():
261 dataIdText += f", Tract: {plotInfo['tract']}"
262 if "visit" in plotInfo.keys():
263 dataIdText += f", Visit: {plotInfo['visit']}"
265 bandText = ""
266 for band in plotInfo["bands"]:
267 bandText += band + ", "
268 bandsText = f", Bands: {bandText[:-2]}"
269 infoText = f"\n{run}{datasetsUsed}{tableType}{dataIdText}{bandsText}"
271 # Find S/N and mag keys, if present.
272 snKeys = []
273 magKeys = []
274 selectionKeys = []
275 selectionPrefix = "Selection: "
276 for key, value in plotInfo.items():
277 if "SN" in key or "S/N" in key:
278 snKeys.append(key)
279 elif "Mag" in key:
280 magKeys.append(key)
281 elif key.startswith(selectionPrefix):
282 selectionKeys.append(key)
283 # Add S/N and mag values to label, if present.
284 # TODO: Do something if there are multiple sn/mag keys. Log? Warn?
285 newline = "\n"
286 if snKeys:
287 infoText = f"{infoText}{newline if magKeys else ', '}{snKeys[0]}{plotInfo.get(snKeys[0])}"
288 if magKeys:
289 infoText = f"{infoText}, {magKeys[0]}{plotInfo.get(magKeys[0])}"
290 if selectionKeys:
291 nPrefix = len(selectionPrefix)
292 selections = ", ".join(f"{key[nPrefix:]}: {plotInfo[key]}" for key in selectionKeys)
293 infoText = f"{infoText}, Selections: {selections}"
295 return infoText
298def addPlotInfo(fig: Figure, plotInfo: Mapping[str, str]) -> Figure:
299 """Add useful information to the plot.
301 Parameters
302 ----------
303 fig : `matplotlib.figure.Figure`
304 The figure to add the information to.
305 plotInfo : `dict`
306 A dictionary of the plot information.
308 Returns
309 -------
310 fig : `matplotlib.figure.Figure`
311 The figure with the information added.
312 """
313 fig.text(0.01, 0.99, plotInfo["plotName"], fontsize=7, transform=fig.transFigure, ha="left", va="top")
314 infoText = parsePlotInfo(plotInfo)
315 fig.text(0.01, 0.984, infoText, fontsize=6, transform=fig.transFigure, alpha=0.6, ha="left", va="top")
317 return fig
320def mkColormap(colorNames):
321 """Make a colormap from the list of color names.
323 Parameters
324 ----------
325 colorNames : `list`
326 A list of strings that correspond to matplotlib named colors.
328 Returns
329 -------
330 cmap : `matplotlib.colors.LinearSegmentedColormap`
331 A colormap stepping through the supplied list of names.
332 """
333 blues = []
334 greens = []
335 reds = []
336 alphas = []
338 if len(colorNames) == 1:
339 # Alpha is between 0 and 1 really but
340 # using 1.5 saturates out the top of the
341 # colorscale, this looks good for ComCam data
342 # but might want to be changed in the future.
343 alphaRange = [0.3, 1.0]
344 nums = np.linspace(0, 1, len(alphaRange))
345 r, g, b = colors.colorConverter.to_rgb(colorNames[0])
346 for num, alpha in zip(nums, alphaRange):
347 blues.append((num, b, b))
348 greens.append((num, g, g))
349 reds.append((num, r, r))
350 alphas.append((num, alpha, alpha))
352 else:
353 nums = np.linspace(0, 1, len(colorNames))
354 if len(colorNames) == 3:
355 alphaRange = [1.0, 0.3, 1.0]
356 elif len(colorNames) == 5:
357 alphaRange = [1.0, 0.7, 0.3, 0.7, 1.0]
358 else:
359 alphaRange = np.ones(len(colorNames))
361 for num, color, alpha in zip(nums, colorNames, alphaRange):
362 r, g, b = colors.colorConverter.to_rgb(color)
363 blues.append((num, b, b))
364 greens.append((num, g, g))
365 reds.append((num, r, r))
366 alphas.append((num, alpha, alpha))
368 colorDict = {"blue": blues, "red": reds, "green": greens, "alpha": alphas}
369 cmap = colors.LinearSegmentedColormap("newCmap", colorDict)
370 return cmap
373def extremaSort(xs):
374 """Return the IDs of the points reordered so that those furthest from the
375 median, in absolute terms, are last.
377 Parameters
378 ----------
379 xs : `np.array`
380 An array of the values to sort
382 Returns
383 -------
384 ids : `np.array`
385 """
386 med = nanMedian(xs)
387 dists = np.abs(xs - med)
388 ids = np.argsort(dists)
389 return ids
392def sortAllArrays(arrsToSort, sortArrayIndex=0):
393 """Sort one array and then return all the others in the associated order.
395 Parameters
396 ----------
397 arrsToSort : `list` [`np.array`]
398 A list of arrays to be simultaneously sorted based on the array in
399 the list position given by ``sortArrayIndex`` (defaults to be the
400 first array in the list).
401 sortArrayIndex : `int`, optional
402 Zero-based index indicating the array on which to base the sorting.
404 Returns
405 -------
406 arrsToSort : `list` [`np.array`]
407 The list of arrays sorted on array in list index ``sortArrayIndex``.
408 """
409 ids = extremaSort(arrsToSort[sortArrayIndex])
410 for i, arr in enumerate(arrsToSort):
411 arrsToSort[i] = arr[ids]
412 return arrsToSort
415def addSummaryPlot(fig, loc, sumStats, label):
416 """Add a summary subplot to the figure.
418 Parameters
419 ----------
420 fig : `matplotlib.figure.Figure`
421 The figure that the summary plot is to be added to.
422 loc : `matplotlib.gridspec.SubplotSpec` or `int` or `(int, int, index`
423 Describes the location in the figure to put the summary plot,
424 can be a gridspec SubplotSpec, a 3 digit integer where the first
425 digit is the number of rows, the second is the number of columns
426 and the third is the index. This is the same for the tuple
427 of int, int, index.
428 sumStats : `dict`
429 A dictionary where the patchIds are the keys which store the R.A.
430 and the dec of the corners of the patch, along with a summary
431 statistic for each patch.
432 label : `str`
433 The label to be used for the colorbar.
435 Returns
436 -------
437 fig : `matplotlib.figure.Figure`
438 """
439 # Add the subplot to the relevant place in the figure
440 # and sort the axis out
441 axCorner = fig.add_subplot(loc)
442 axCorner.yaxis.tick_right()
443 axCorner.yaxis.set_label_position("right")
444 axCorner.xaxis.tick_top()
445 axCorner.xaxis.set_label_position("top")
446 axCorner.set_aspect("equal")
448 # Plot the corners of the patches and make the color
449 # coded rectangles for each patch, the colors show
450 # the median of the given value in the patch
451 patches = []
452 colors = []
453 for dataId in sumStats.keys():
454 (corners, stat) = sumStats[dataId]
455 ra = corners[0][0].asDegrees()
456 dec = corners[0][1].asDegrees()
457 xy = (ra, dec)
458 width = corners[2][0].asDegrees() - ra
459 height = corners[2][1].asDegrees() - dec
460 patches.append(Rectangle(xy, width, height))
461 colors.append(stat)
462 ras = [ra.asDegrees() for (ra, dec) in corners]
463 decs = [dec.asDegrees() for (ra, dec) in corners]
464 axCorner.plot(ras + [ras[0]], decs + [decs[0]], "k", lw=0.5)
465 cenX = ra + width / 2
466 cenY = dec + height / 2
467 if dataId != "tract":
468 axCorner.annotate(dataId, (cenX, cenY), color="k", fontsize=4, ha="center", va="center")
470 # Set the bad color to transparent and make a masked array
471 cmapPatch = cm.coolwarm.copy()
472 cmapPatch.set_bad(color="none")
473 colors = np.ma.array(colors, mask=np.isnan(colors))
474 collection = PatchCollection(patches, cmap=cmapPatch)
475 collection.set_array(colors)
476 axCorner.add_collection(collection)
478 # Add some labels
479 axCorner.set_xlabel("R.A. (deg)", fontsize=7)
480 axCorner.set_ylabel("Dec. (deg)", fontsize=7)
481 axCorner.tick_params(axis="both", labelsize=6, length=0, pad=1.5)
482 axCorner.invert_xaxis()
484 # Add a colorbar
485 pos = axCorner.get_position()
486 yOffset = (pos.y1 - pos.y0) / 3
487 cax = fig.add_axes([pos.x0, pos.y1 + yOffset, pos.x1 - pos.x0, 0.025])
488 fig.colorbar(collection, cax=cax, orientation="horizontal")
489 cornerLabel = "Median of patch\nvalues for\ny axis"
490 axCorner.text(1.1, 1.3, cornerLabel, transform=axCorner.transAxes, fontsize=6)
491 cax.tick_params(
492 axis="x", labelsize=6, labeltop=True, labelbottom=False, bottom=False, top=True, pad=0.5, length=2
493 )
495 return fig
498def shorten_list(numbers: Iterable[int], *, range_indicator: str = "-", range_separator: str = ",") -> str:
499 """Shorten an iterable of integers.
501 Parameters
502 ----------
503 numbers : `~collections.abc.Iterable` [`int`]
504 Any iterable (list, set, tuple, numpy.array) of integers.
505 range_indicator : `str`, optional
506 The string to use to indicate a range of numbers.
507 range_separator : `str`, optional
508 The string to use to separate ranges of numbers.
510 Returns
511 -------
512 result : `str`
513 A shortened string representation of the list.
515 Examples
516 --------
517 >>> shorten_list([1,2,3,5,6,8])
518 "1-3,5-6,8"
520 >>> shorten_list((1,2,3,5,6,8,9,10,11), range_separator=", ")
521 "1-3, 5-6, 8-11"
523 >>> shorten_list(range(4), range_indicator="..")
524 "0..3"
525 """
526 # Sort the list in ascending order.
527 numbers = sorted(numbers)
529 if not numbers: # empty container
530 return ""
532 # Initialize an empty list to hold the results to be returned.
533 result = []
535 # Initialize variables to track the current start and end of a list.
536 start = 0
537 end = 0 # initialize to 0 to handle single element lists.
539 # Iterate through the sorted list of numbers
540 for end in range(1, len(numbers)):
541 # If the current number is the same or consecutive to the previous
542 # number, skip to the next iteration.
543 if numbers[end] > numbers[end - 1] + 1: # > is used to handle duplicates, if any.
544 # If the current number is not consecutive to the previous number,
545 # add the current range to the result and reset the start to end.
546 if start == end - 1:
547 result.append(str(numbers[start]))
548 else:
549 result.append(range_indicator.join((str(numbers[start]), str(numbers[end - 1]))))
551 # Update start.
552 start = end
554 # Add the final range to the result.
555 if start == end:
556 result.append(str(numbers[start]))
557 else:
558 result.append(range_indicator.join((str(numbers[start]), str(numbers[end]))))
560 # Return the shortened string representation.
561 return range_separator.join(result)
564class PanelConfig(Config):
565 """Configuration options for the plot panels used by DiaSkyPlot.
567 The defaults will produce a good-looking single panel plot.
568 The subplot2grid* fields correspond to matplotlib.pyplot.subplot2grid.
569 """
571 topSpinesVisible = Field[bool](
572 doc="Draw line and ticks on top of panel?",
573 default=False,
574 )
575 bottomSpinesVisible = Field[bool](
576 doc="Draw line and ticks on bottom of panel?",
577 default=True,
578 )
579 leftSpinesVisible = Field[bool](
580 doc="Draw line and ticks on left side of panel?",
581 default=True,
582 )
583 rightSpinesVisible = Field[bool](
584 doc="Draw line and ticks on right side of panel?",
585 default=True,
586 )
587 subplot2gridShapeRow = Field[int](
588 doc="Number of rows of the grid in which to place axis.",
589 default=10,
590 )
591 subplot2gridShapeColumn = Field[int](
592 doc="Number of columns of the grid in which to place axis.",
593 default=10,
594 )
595 subplot2gridLocRow = Field[int](
596 doc="Row of the axis location within the grid.",
597 default=1,
598 )
599 subplot2gridLocColumn = Field[int](
600 doc="Column of the axis location within the grid.",
601 default=1,
602 )
603 subplot2gridRowspan = Field[int](
604 doc="Number of rows for the axis to span downwards.",
605 default=5,
606 )
607 subplot2gridColspan = Field[int](
608 doc="Number of rows for the axis to span to the right.",
609 default=5,
610 )
613def plotProjectionWithBinning(
614 ax,
615 xs,
616 ys,
617 zs,
618 cmap,
619 xMin,
620 xMax,
621 yMin,
622 yMax,
623 xNumBins=45,
624 yNumBins=None,
625 fixAroundZero=False,
626 nPointBinThresh=5000,
627 isSorted=False,
628 vmin=None,
629 vmax=None,
630 showExtremeOutliers=True,
631 scatPtSize=7,
632):
633 """Plot color-mapped data in projection and with binning when appropriate.
635 Parameters
636 ----------
637 ax : `matplotlib.axes.Axes`
638 Axis on which to plot the projection data.
639 xs, ys : `np.array`
640 Arrays containing the x and y positions of the data.
641 zs : `np.array`
642 Array containing the scaling value associated with the (``xs``, ``ys``)
643 positions.
644 cmap : `matplotlib.colors.Colormap`
645 Colormap for the ``zs`` values.
646 xMin, xMax, yMin, yMax : `float`
647 Data limits within which to compute bin sizes.
648 xNumBins : `int`, optional
649 The number of bins along the x-axis.
650 yNumBins : `int`, optional
651 The number of bins along the y-axis. If `None`, this is set to equal
652 ``xNumBins``.
653 nPointBinThresh : `int`, optional
654 Threshold number of points above which binning will be implemented
655 for the plotting. If the number of data points is lower than this
656 threshold, a basic scatter plot will be generated.
657 isSorted : `bool`, optional
658 Whether the data have been sorted in ``zs`` (the sorting is to
659 accommodate the overplotting of points in the upper and lower
660 extrema of the data).
661 vmin, vmax : `float`, optional
662 The min and max limits for the colorbar.
663 showExtremeOutliers: `bool`, default True
664 Use overlaid scatter points to show the x-y positions of the 15%
665 most extreme values.
666 scatPtSize : `float`, optional
667 The point size to use if just plotting a regular scatter plot.
669 Returns
670 -------
671 plotOut : `matplotlib.collections.PathCollection`
672 The plot object with ``ax`` updated with data plotted here.
673 """
674 med = nanMedian(zs)
675 mad = nanSigmaMad(zs)
676 if vmin is None:
677 vmin = med - 2 * mad
678 if vmax is None:
679 vmax = med + 2 * mad
680 if fixAroundZero:
681 scaleEnd = np.max([np.abs(vmin), np.abs(vmax)])
682 vmin = -1 * scaleEnd
683 vmax = scaleEnd
685 yNumBins = xNumBins if yNumBins is None else yNumBins
687 xBinEdges = np.linspace(xMin, xMax, xNumBins + 1)
688 yBinEdges = np.linspace(yMin, yMax, yNumBins + 1)
689 finiteMask = np.isfinite(zs)
690 xs = xs[finiteMask]
691 ys = ys[finiteMask]
692 zs = zs[finiteMask]
693 binnedStats, xEdges, yEdges, binNums = binned_statistic_2d(
694 xs, ys, zs, statistic="median", bins=(xBinEdges, yBinEdges)
695 )
697 if len(xs) >= nPointBinThresh:
698 s = min(10, max(0.5, nPointBinThresh / 10 / (len(xs) ** 0.5)))
699 lw = (s**0.5) / 10
700 plotOut = ax.imshow(
701 binnedStats.T,
702 cmap=cmap,
703 extent=[xEdges[0], xEdges[-1], yEdges[-1], yEdges[0]],
704 vmin=vmin,
705 vmax=vmax,
706 )
707 if not isSorted:
708 sortedArrays = sortAllArrays([zs, xs, ys])
709 zs, xs, ys = sortedArrays[0], sortedArrays[1], sortedArrays[2]
710 if len(xs) > 1:
711 if showExtremeOutliers:
712 # Find the most extreme 15% of points. The list is ordered
713 # by the distance from the median, this is just the
714 # head/tail 15% of points.
715 extremes = int(np.floor(len(xs) / 100) * 85)
716 plotOut = ax.scatter(
717 xs[extremes:],
718 ys[extremes:],
719 c=zs[extremes:],
720 s=s,
721 cmap=cmap,
722 vmin=vmin,
723 vmax=vmax,
724 edgecolor="white",
725 linewidths=lw,
726 )
727 else:
728 plotOut = ax.scatter(
729 xs,
730 ys,
731 c=zs,
732 cmap=cmap,
733 s=scatPtSize,
734 vmin=vmin,
735 vmax=vmax,
736 edgecolor="white",
737 linewidths=0.2,
738 )
739 return plotOut