From 815247807f95fc3ce5aeab86d2ed3be10c363f4c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 25 Dec 2025 09:57:49 +0000 Subject: [PATCH 1/2] fix: correct step hist autoscaling --- lib/matplotlib/axes/_axes.py | 66 ++++++++++++++------- lib/matplotlib/tests/test_axes/test_hist.py | 60 +++++++++++++++++++ 2 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 lib/matplotlib/tests/test_axes/test_hist.py diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 2412f815cd42..150feee059d6 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -36,6 +36,16 @@ from matplotlib.axes._secondary_axes import SecondaryAxis from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer + + +class _HistPathPatch(mpatches.PathPatch): + """PathPatch variant exposing Polygon-like helpers used by hist tests.""" + + def get_xy(self): + return self.get_path().vertices + + + _log = logging.getLogger(__name__) @@ -6870,28 +6880,42 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, xvals.append(x.copy()) yvals.append(y.copy()) - # stepfill is closed, step is not split = -1 if fill else 2 * len(bins) - # add patches in reverse order so that when stacking, - # items lower in the stack are plotted on top of - # items higher in the stack - for x, y, c in reversed(list(zip(xvals, yvals, color))): - patches.append(self.fill( - x[:split], y[:split], - closed=True if fill else None, - facecolor=c, - edgecolor=None if fill else c, - fill=fill if fill else None, - zorder=None if fill else mlines.Line2D.zorder)) - for patch_list in patches: - for patch in patch_list: - if orientation == 'vertical': - patch.sticky_edges.y.append(0) - elif orientation == 'horizontal': - patch.sticky_edges.x.append(0) - - # we return patches, so put it back in the expected order - patches.reverse() + path_patches = [] + for x_vals, y_vals, c in reversed(list(zip(xvals, yvals, color))): + xs = x_vals[:split] + ys = y_vals[:split] + vertices = np.column_stack([xs, ys]) + codes = np.full(len(vertices), mpath.Path.LINETO, + dtype=mpath.Path.code_type) + codes[0] = mpath.Path.MOVETO + if fill: + vertices = np.vstack([vertices, vertices[:1]]) + codes = np.append(codes, mpath.Path.CLOSEPOLY) + path = mpath.Path(vertices, codes) + path.should_simplify = False + if fill: + facecolor = c + edgecolor = None + else: + facecolor = c + edgecolor = c + patch = _HistPathPatch( + path, + facecolor=facecolor, + edgecolor=edgecolor, + fill=fill, + zorder=None if fill else mlines.Line2D.zorder, + ) + self.add_patch(patch) + if orientation == 'vertical': + patch.sticky_edges.y.append(0) + else: # orientation == 'horizontal' + patch.sticky_edges.x.append(0) + path_patches.append([patch]) + + patches = list(reversed(path_patches)) + self._request_autoscale_view() # If None, make all labels None (via zip_longest below); otherwise, # cast each element to str, but keep a single str as it. diff --git a/lib/matplotlib/tests/test_axes/test_hist.py b/lib/matplotlib/tests/test_axes/test_hist.py new file mode 100644 index 000000000000..9c7169ad7e46 --- /dev/null +++ b/lib/matplotlib/tests/test_axes/test_hist.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import matplotlib.pyplot as plt + + +def _generate_density_samples( + *, + seed: int = 20250101, + size: int = 100_000, + scale: float = 1.2, +) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.standard_normal(size) * scale + + +def _hist_peak_and_ylim( + histtype: str, values: np.ndarray, +) -> tuple[float, float]: + fig, ax = plt.subplots() + try: + counts, _, _ = ax.hist( + values, + bins=100, + density=True, + histtype=histtype, + ) + peak = float(counts.max()) if counts.size else 0.0 + upper = float(ax.get_ylim()[1]) + finally: + plt.close(fig) + return peak, upper + + +def test_hist_step_density_autoscale_includes_peak() -> None: + values = _generate_density_samples() + peak, upper = _hist_peak_and_ylim("step", values) + assert upper >= peak + + +def test_hist_step_density_scale_invariance() -> None: + values = _generate_density_samples() + peak_base, upper_base = _hist_peak_and_ylim("step", values) + scaled_values = values * 3.0 + peak_scaled, upper_scaled = _hist_peak_and_ylim("step", scaled_values) + + assert upper_base >= peak_base + assert upper_scaled >= peak_scaled + + ratio_base = upper_base / peak_base + ratio_scaled = upper_scaled / peak_scaled + assert ratio_scaled == pytest.approx(ratio_base, rel=0.05) + + +def test_hist_stepfilled_density_autoscale_includes_peak() -> None: + values = _generate_density_samples() + peak, upper = _hist_peak_and_ylim("stepfilled", values) + assert upper >= peak From 52ae4b998c1284d8bf21d80bdd1c09cb8828e3dc Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 25 Dec 2025 10:12:20 +0000 Subject: [PATCH 2/2] fix: restore polygon hist patches --- lib/matplotlib/axes/_axes.py | 58 ++++++++++++++---------------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 150feee059d6..eecf56c5256b 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -37,15 +37,6 @@ from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer - -class _HistPathPatch(mpatches.PathPatch): - """PathPatch variant exposing Polygon-like helpers used by hist tests.""" - - def get_xy(self): - return self.get_path().vertices - - - _log = logging.getLogger(__name__) @@ -6881,40 +6872,35 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, yvals.append(y.copy()) split = -1 if fill else 2 * len(bins) - path_patches = [] + fill_kwarg = kwargs.get('fill', None) + if fill_kwarg is None: + fill_requested = False + elif (np.iterable(fill_kwarg) + and not isinstance(fill_kwarg, (str, bytes))): + fill_requested = any(fill_kwarg) + else: + fill_requested = bool(fill_kwarg) + polygon_lists = [] for x_vals, y_vals, c in reversed(list(zip(xvals, yvals, color))): - xs = x_vals[:split] - ys = y_vals[:split] - vertices = np.column_stack([xs, ys]) - codes = np.full(len(vertices), mpath.Path.LINETO, - dtype=mpath.Path.code_type) - codes[0] = mpath.Path.MOVETO - if fill: - vertices = np.vstack([vertices, vertices[:1]]) - codes = np.append(codes, mpath.Path.CLOSEPOLY) - path = mpath.Path(vertices, codes) - path.should_simplify = False - if fill: - facecolor = c - edgecolor = None - else: - facecolor = c - edgecolor = c - patch = _HistPathPatch( - path, - facecolor=facecolor, - edgecolor=edgecolor, + vertices = np.column_stack([x_vals[:split], y_vals[:split]]) + facecolor = c if fill or fill_requested else 'none' + poly = mpatches.Polygon( + vertices, + closed=fill, fill=fill, + edgecolor=None if fill else c, + facecolor=facecolor, zorder=None if fill else mlines.Line2D.zorder, ) - self.add_patch(patch) + poly.get_path().should_simplify = False + self.add_patch(poly) if orientation == 'vertical': - patch.sticky_edges.y.append(0) + poly.sticky_edges.y.append(0) else: # orientation == 'horizontal' - patch.sticky_edges.x.append(0) - path_patches.append([patch]) + poly.sticky_edges.x.append(0) + polygon_lists.append([poly]) - patches = list(reversed(path_patches)) + patches = list(reversed(polygon_lists)) self._request_autoscale_view() # If None, make all labels None (via zip_longest below); otherwise,