From 5bdf85123c68ceb4c605ac59293594e76fbeb314 Mon Sep 17 00:00:00 2001 From: AdityaJagtap18 Date: Sun, 6 Sep 2026 17:11:32 +0530 Subject: [PATCH 1/2] Apply unit conversion to hist2d's x/y/range, fixing datetime input hist2d passed x and y directly to np.histogram2d without applying the axes' unit converters, unlike plot, scatter, and hist. Datetime64 input therefore reached numpy as raw (nanosecond-scale) integers instead of Matplotlib's internal date representation, so a hist2d plot didn't line up with other artists on the same Axes sharing the same datetime data -- on current numpy this now raises a hard TypeError instead (numpy no longer silently mixes datetime64 and float), so the bug currently manifests as a crash rather than silently wrong bin edges. Convert x, y, and range (if given) through _process_unit_info / convert_xunits / convert_yunits before binning, mirroring the pattern hist already uses for its own x input. Plain numeric input is unaffected since unit conversion is a no-op without units set. Fixes #17319 Co-Authored-By: Claude Sonnet 5 --- .../behavior/hist2d_unit_conversion.rst | 11 +++++++ lib/matplotlib/axes/_axes.py | 12 +++++++ lib/matplotlib/tests/test_axes.py | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 doc/api/next_api_changes/behavior/hist2d_unit_conversion.rst diff --git a/doc/api/next_api_changes/behavior/hist2d_unit_conversion.rst b/doc/api/next_api_changes/behavior/hist2d_unit_conversion.rst new file mode 100644 index 000000000000..56db226ceb47 --- /dev/null +++ b/doc/api/next_api_changes/behavior/hist2d_unit_conversion.rst @@ -0,0 +1,11 @@ +``Axes.hist2d`` now converts *x*/*y*/*range* through the axis unit converters +------------------------------------------------------------------------------ + +`~matplotlib.axes.Axes.hist2d` previously passed *x* and *y* directly to +`numpy.histogram2d` without applying the axes' unit converters, unlike +`~.Axes.plot`, `~.Axes.scatter`, and `~.Axes.hist`. This meant that, e.g., +plotting ``datetime64`` data with `~.Axes.hist2d` produced bin edges in raw +(often nanosecond-scale) units instead of Matplotlib's internal date +representation, so the histogram did not line up with other artists plotted +on the same Axes. ``x``, ``y``, and ``range`` (if passed) are now converted +consistently with the other plotting methods. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index c71c260b9ed1..27be4babf62c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -7992,8 +7992,20 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, Previously, `~.Axes.hist2d` would force the axes limits to match the extents of the histogram; now, autoscaling also takes other plot elements into account. + + .. versionchanged:: 3.12 + *x* and *y* (and *range*, if given) are now passed through the axes' + unit converters before binning, so e.g. datetime inputs are handled + the same way as in `~.Axes.plot` and `~.Axes.scatter`. Previously + they were passed unconverted to `numpy.histogram2d`. """ + x, y = self._process_unit_info([("x", x), ("y", y)], kwargs) + if range is not None: + (xmin, xmax), (ymin, ymax) = range + range = (self.convert_xunits((xmin, xmax)), + self.convert_yunits((ymin, ymax))) + h, xedges, yedges = np.histogram2d(x, y, bins=bins, range=range, density=density, weights=weights) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 968c41d61c02..053ad6344f21 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2919,6 +2919,37 @@ def test_hist2d_autolimits(): assert ax.get_autoscale_on() # Autolimits have not been disabled. +def test_hist2d_datetime(): + # Regression test for gh-17319: hist2d must apply the same unit + # conversion to x/y as plot()/scatter() so datetime input lands on the + # same (small, date2num-like) numeric scale, instead of raw + # nanosecond-scale datetime64 integers. + x = np.arange(np.datetime64('2020-01-01'), np.datetime64('2020-01-11')) + y = np.arange(10.) + + ax_hist2d = plt.figure().add_subplot() + ax_hist2d.hist2d(x, y, bins=5) + + ax_plot = plt.figure().add_subplot() + ax_plot.plot(x, y) + + assert ax_hist2d.get_xlim() == ax_plot.get_xlim() + # sanity check: this is a small (date2num-like) scale, not raw datetime64 + assert all(abs(lim) < 1e6 for lim in ax_hist2d.get_xlim()) + + +def test_hist2d_datetime_range(): + # The `range` parameter should also be converted, so datetime bounds + # work the same way as passing already-converted (float) bounds. + x = np.arange(np.datetime64('2020-01-01'), np.datetime64('2020-01-11')) + y = np.arange(10.) + xlim = np.array([np.datetime64('2020-01-01'), np.datetime64('2020-01-11')]) + + h, xedges, yedges, pc = plt.figure().add_subplot().hist2d( + x, y, bins=5, range=[xlim, [0, 10]]) + assert all(abs(e) < 1e6 for e in (xedges[0], xedges[-1])) + + class TestScatter: @image_comparison(['scatter'], style='mpl20', remove_text=True) def test_scatter_plot(self): From 5a7348e10bc51698af735277537aa1cd6ec9ab7d Mon Sep 17 00:00:00 2001 From: AdityaJagtap18 Date: Mon, 7 Sep 2026 10:14:13 +0530 Subject: [PATCH 2/2] Test user-facing hist2d output instead of internal date representation Per review from @jklymak: the previous tests hardcoded an assumption about date2num's internal scale (asserting values are "< 1e6"), which would fail needlessly if the epoch or internal units ever changed, without actually testing anything user-facing. Rewrote both tests to compare hist2d's returned bin edges/counts against np.histogram2d called with matplotlib.dates.date2num(x) directly -- the same public conversion function a user would reach for -- rather than asserting anything about the specific numeric scale. This tests exactly the promised behavior (hist2d converts the same way plot()/scatter() do) without any coupling to the internal representation. Verified by monkeypatching the real (unmodified) Axes.hist2d with this PR's fix in a fresh pip install of matplotlib and running both tests directly, plus a negative control confirming they fail against the original, unpatched hist2d (which raises a TypeError mixing datetime64 and float on current numpy, matching the original bug report). Co-Authored-By: Claude Sonnet 5 --- lib/matplotlib/tests/test_axes.py | 47 ++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 053ad6344f21..abfa5aec86c8 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2920,34 +2920,43 @@ def test_hist2d_autolimits(): def test_hist2d_datetime(): - # Regression test for gh-17319: hist2d must apply the same unit - # conversion to x/y as plot()/scatter() so datetime input lands on the - # same (small, date2num-like) numeric scale, instead of raw - # nanosecond-scale datetime64 integers. + # Regression test for gh-17319: hist2d's returned bin edges for + # datetime input should be on the same numeric scale as + # matplotlib.dates.date2num (i.e. equivalent to converting the dates + # yourself and calling np.histogram2d directly), not raw + # (nanosecond-scale) datetime64 integers. x = np.arange(np.datetime64('2020-01-01'), np.datetime64('2020-01-11')) y = np.arange(10.) - ax_hist2d = plt.figure().add_subplot() - ax_hist2d.hist2d(x, y, bins=5) - - ax_plot = plt.figure().add_subplot() - ax_plot.plot(x, y) + ax = plt.figure().add_subplot() + h, xedges, yedges, pc = ax.hist2d(x, y, bins=5) - assert ax_hist2d.get_xlim() == ax_plot.get_xlim() - # sanity check: this is a small (date2num-like) scale, not raw datetime64 - assert all(abs(lim) < 1e6 for lim in ax_hist2d.get_xlim()) + expected_h, expected_xedges, expected_yedges = np.histogram2d( + mdates.date2num(x), y, bins=5) + np.testing.assert_array_equal(xedges, expected_xedges) + np.testing.assert_array_equal(yedges, expected_yedges) + np.testing.assert_array_equal(h, expected_h) def test_hist2d_datetime_range(): - # The `range` parameter should also be converted, so datetime bounds - # work the same way as passing already-converted (float) bounds. + # The `range` parameter should be converted through the unit converters + # just like x/y, so passing datetime bounds is equivalent to manually + # converting them (e.g. via `date2num`) and passing the result. x = np.arange(np.datetime64('2020-01-01'), np.datetime64('2020-01-11')) y = np.arange(10.) - xlim = np.array([np.datetime64('2020-01-01'), np.datetime64('2020-01-11')]) - - h, xedges, yedges, pc = plt.figure().add_subplot().hist2d( - x, y, bins=5, range=[xlim, [0, 10]]) - assert all(abs(e) < 1e6 for e in (xedges[0], xedges[-1])) + xlim_datetime = [np.datetime64('2020-01-01'), np.datetime64('2020-01-11')] + xlim_converted = mdates.date2num(xlim_datetime) + + h_datetime, xedges_datetime, yedges_datetime, _ = ( + plt.figure().add_subplot().hist2d( + x, y, bins=5, range=[xlim_datetime, [0, 10]])) + h_converted, xedges_converted, yedges_converted, _ = ( + plt.figure().add_subplot().hist2d( + mdates.date2num(x), y, bins=5, range=[xlim_converted, [0, 10]])) + + np.testing.assert_array_equal(xedges_datetime, xedges_converted) + np.testing.assert_array_equal(yedges_datetime, yedges_converted) + np.testing.assert_array_equal(h_datetime, h_converted) class TestScatter: