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..abfa5aec86c8 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2919,6 +2919,46 @@ def test_hist2d_autolimits(): assert ax.get_autoscale_on() # Autolimits have not been disabled. +def test_hist2d_datetime(): + # 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 = plt.figure().add_subplot() + h, xedges, yedges, pc = ax.hist2d(x, y, bins=5) + + 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 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_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: @image_comparison(['scatter'], style='mpl20', remove_text=True) def test_scatter_plot(self):