From 3caf8e6c274bab9124cf6e16feccd39a1808f185 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 24 Mar 2025 16:19:40 +0700 Subject: [PATCH 01/11] Handle line and box on the same plot --- pandas/plotting/_matplotlib/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pandas/plotting/_matplotlib/core.py b/pandas/plotting/_matplotlib/core.py index 1035150302d2c..eae29d7ccd4f4 100644 --- a/pandas/plotting/_matplotlib/core.py +++ b/pandas/plotting/_matplotlib/core.py @@ -1855,7 +1855,6 @@ def __init__( self.bar_width = width self._align = align self._position = position - self.tick_pos = np.arange(len(data)) if is_list_like(bottom): bottom = np.array(bottom) @@ -1868,6 +1867,12 @@ def __init__( MPLPlot.__init__(self, data, **kwargs) + self.tick_pos = ( + np.array(self.ax.xaxis.convert_units(self._get_xticks())) + if isinstance(data.index, ABCPeriodIndex) + else np.arange(len(data)) + ) + @cache_readonly def ax_pos(self) -> np.ndarray: return self.tick_pos - self.tickoffset From 97d0440c52b52c24951b2e6278f9bc50c2bb1e2c Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 24 Mar 2025 17:51:06 +0700 Subject: [PATCH 02/11] Handle line and box on the same plot --- pandas/plotting/_matplotlib/core.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pandas/plotting/_matplotlib/core.py b/pandas/plotting/_matplotlib/core.py index eae29d7ccd4f4..db9ca709172b3 100644 --- a/pandas/plotting/_matplotlib/core.py +++ b/pandas/plotting/_matplotlib/core.py @@ -59,7 +59,10 @@ from pandas.io.formats.printing import pprint_thing from pandas.plotting._matplotlib import tools -from pandas.plotting._matplotlib.converter import register_pandas_matplotlib_converters +from pandas.plotting._matplotlib.converter import ( + PeriodConverter, + register_pandas_matplotlib_converters, +) from pandas.plotting._matplotlib.groupby import reconstruct_data_with_by from pandas.plotting._matplotlib.misc import unpack_single_str_list from pandas.plotting._matplotlib.style import get_standard_colors @@ -1867,11 +1870,13 @@ def __init__( MPLPlot.__init__(self, data, **kwargs) - self.tick_pos = ( - np.array(self.ax.xaxis.convert_units(self._get_xticks())) - if isinstance(data.index, ABCPeriodIndex) - else np.arange(len(data)) - ) + if isinstance(data.index, ABCPeriodIndex): + self.ax.freq = data.index.freq + self.tick_pos = np.array( + PeriodConverter.convert(data.index._mpl_repr(), None, self.ax) + ) + else: + self.tick_pos = np.arange(len(data)) @cache_readonly def ax_pos(self) -> np.ndarray: From 5fb6172e6b1f50bd609e421f793ff38e9fc277fc Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 7 Apr 2025 22:34:05 +0700 Subject: [PATCH 03/11] Refactor PeriodConverter to separate frequency handling. Introduce `convert_from_freq` method and streamline the conversion process by passing `freq` directly instead of using `axis.freq`. This improves modularity and ensures clearer separation of concerns for frequency handling in Period conversion. --- pandas/plotting/_matplotlib/converter.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pandas/plotting/_matplotlib/converter.py b/pandas/plotting/_matplotlib/converter.py index 4c00049075d03..774062e0f0412 100644 --- a/pandas/plotting/_matplotlib/converter.py +++ b/pandas/plotting/_matplotlib/converter.py @@ -225,16 +225,20 @@ def __call__(self, x, pos: int | None = 0) -> str: class PeriodConverter(mdates.DateConverter): @staticmethod def convert(values, units, axis): + if not hasattr(axis, "freq"): + raise TypeError("Axis must have `freq` set to convert to Periods") + return PeriodConverter.convert_from_freq(values, axis.freq) + + @staticmethod + def convert_from_freq(values, freq): if is_nested_list_like(values): - values = [PeriodConverter._convert_1d(v, units, axis) for v in values] + values = [PeriodConverter._convert_1d(v, freq) for v in values] else: - values = PeriodConverter._convert_1d(values, units, axis) + values = PeriodConverter._convert_1d(values, freq) return values @staticmethod - def _convert_1d(values, units, axis): - if not hasattr(axis, "freq"): - raise TypeError("Axis must have `freq` set to convert to Periods") + def _convert_1d(values, freq): valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64) with warnings.catch_warnings(): warnings.filterwarnings( @@ -248,17 +252,17 @@ def _convert_1d(values, units, axis): or is_integer(values) or is_float(values) ): - return get_datevalue(values, axis.freq) + return get_datevalue(values, freq) elif isinstance(values, PeriodIndex): - return values.asfreq(axis.freq).asi8 + return values.asfreq(freq).asi8 elif isinstance(values, Index): - return values.map(lambda x: get_datevalue(x, axis.freq)) + return values.map(lambda x: get_datevalue(x, freq)) elif lib.infer_dtype(values, skipna=False) == "period": # https://github.com/pandas-dev/pandas/issues/24304 # convert ndarray[period] -> PeriodIndex - return PeriodIndex(values, freq=axis.freq).asi8 + return PeriodIndex(values, freq=freq).asi8 elif isinstance(values, (list, tuple, np.ndarray, Index)): - return [get_datevalue(x, axis.freq) for x in values] + return [get_datevalue(x, freq) for x in values] return values From ee183d7a12ec98c26e9bedc051259b8819b9a527 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 7 Apr 2025 22:35:31 +0700 Subject: [PATCH 04/11] Refactor time series plotting logic in Matplotlib backend. Simplified `tick_pos` calculation by reusing helper methods and added a decorator to register pandas Matplotlib converters in the `_plot` method. This improves clarity and ensures proper integration with the pandas Matplotlib ecosystem. --- pandas/plotting/_matplotlib/core.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pandas/plotting/_matplotlib/core.py b/pandas/plotting/_matplotlib/core.py index db9ca709172b3..65c857b03adb1 100644 --- a/pandas/plotting/_matplotlib/core.py +++ b/pandas/plotting/_matplotlib/core.py @@ -1870,10 +1870,12 @@ def __init__( MPLPlot.__init__(self, data, **kwargs) - if isinstance(data.index, ABCPeriodIndex): - self.ax.freq = data.index.freq + if self._is_ts_plot(): self.tick_pos = np.array( - PeriodConverter.convert(data.index._mpl_repr(), None, self.ax) + PeriodConverter.convert_from_freq( + self._get_xticks(), + data.index.freq, + ) ) else: self.tick_pos = np.arange(len(data)) @@ -1907,6 +1909,7 @@ def lim_offset(self): # error: Signature of "_plot" incompatible with supertype "MPLPlot" @classmethod + @register_pandas_matplotlib_converters def _plot( # type: ignore[override] cls, ax: Axes, From e5b4e6fecde45eefeddfef2837a6e22e0b08fe12 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 7 Apr 2025 22:48:09 +0700 Subject: [PATCH 05/11] Add test for bar and line plot superposition with same x values This test ensures that bar and line plots with identical x values are correctly superposed on the same axes. It verifies that the x-tick positions remain consistent across plot types. --- pandas/tests/plotting/test_series.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pandas/tests/plotting/test_series.py b/pandas/tests/plotting/test_series.py index c3b0219971446..da6c4db550a4e 100644 --- a/pandas/tests/plotting/test_series.py +++ b/pandas/tests/plotting/test_series.py @@ -971,3 +971,17 @@ def test_secondary_y_subplot_axis_labels(self): s1.plot(ax=ax2) assert len(ax.xaxis.get_minor_ticks()) == 0 assert len(ax.get_xticklabels()) > 0 + + def test_bar_line_plot(self): + """ + Test that bar and line plots with the same x values are superposed + """ + # GH61161 + index = period_range("2023", periods=3, freq="Y") + s = Series([1, 2, 3], index=index) + ax = plt.subplot() + s.plot(kind="bar", ax=ax) + bar_xticks = ax.get_xticks().tolist() + s.plot(kind="line", ax=ax, color="r") + line_xticks = ax.get_xticks()[: len(s)].tolist() + assert line_xticks == bar_xticks From 218871dd758518021a8ee5ff80ab6d0d14223583 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 7 Apr 2025 22:59:54 +0700 Subject: [PATCH 06/11] Fix alignment issue in Series.plot with line and bar. Resolved a bug that prevented a line and bar from aligning on the same plot in `Series.plot`. This addresses issue #61161 and improves plot consistency when combining these chart types. --- doc/source/whatsnew/v3.0.0.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index e6fafc8b1b14c..33cf86c271b22 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -762,6 +762,7 @@ Plotting - Bug in :meth:`DataFrame.plot.bar` with ``stacked=True`` where labels on stacked bars with zero-height segments were incorrectly positioned at the base instead of the label position of the previous segment (:issue:`59429`) - Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`) - Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`) +- Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`61161`) - Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`) Groupby/resample/rolling From 16762b6f4acf119094b10bb1aaa0374b73444b42 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 7 Apr 2025 23:08:13 +0700 Subject: [PATCH 07/11] Refactor time series handling in matplotlib plotting. Move `x_compat` logic and time series helper methods from `LinePlot` to `MPLPlot` for better reusability and maintainability. This simplifies the `LinePlot` class and centralizes common functionality. --- pandas/plotting/_matplotlib/core.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pandas/plotting/_matplotlib/core.py b/pandas/plotting/_matplotlib/core.py index 65c857b03adb1..302e0c4762d85 100644 --- a/pandas/plotting/_matplotlib/core.py +++ b/pandas/plotting/_matplotlib/core.py @@ -291,6 +291,21 @@ def __init__( self.data = self._ensure_frame(self.data) + from pandas.plotting import plot_params + + self.x_compat = plot_params["x_compat"] + if "x_compat" in self.kwds: + self.x_compat = bool(self.kwds.pop("x_compat")) + + @final + def _is_ts_plot(self) -> bool: + # this is slightly deceptive + return not self.x_compat and self.use_index and self._use_dynamic_x() + + @final + def _use_dynamic_x(self) -> bool: + return use_dynamic_x(self._get_ax(0), self.data) + @final @staticmethod def _validate_sharex(sharex: bool | None, ax, by) -> bool: @@ -1523,23 +1538,9 @@ def _kind(self) -> Literal["line", "area", "hist", "kde", "box"]: return "line" def __init__(self, data, **kwargs) -> None: - from pandas.plotting import plot_params - MPLPlot.__init__(self, data, **kwargs) if self.stacked: self.data = self.data.fillna(value=0) - self.x_compat = plot_params["x_compat"] - if "x_compat" in self.kwds: - self.x_compat = bool(self.kwds.pop("x_compat")) - - @final - def _is_ts_plot(self) -> bool: - # this is slightly deceptive - return not self.x_compat and self.use_index and self._use_dynamic_x() - - @final - def _use_dynamic_x(self) -> bool: - return use_dynamic_x(self._get_ax(0), self.data) def _make_plot(self, fig: Figure) -> None: if self._is_ts_plot(): From b28f9afca89d5c5a6a1e0cf56db49cbab488b99a Mon Sep 17 00:00:00 2001 From: Martin Braquet Date: Wed, 9 Apr 2025 23:53:42 +0700 Subject: [PATCH 08/11] Update doc/source/whatsnew/v3.0.0.rst --- doc/source/whatsnew/v3.0.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 5789e6e6b5997..65b647e512319 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -762,7 +762,7 @@ Plotting - Bug in :meth:`.DataFrameGroupBy.boxplot` failed when there were multiple groupings (:issue:`14701`) - Bug in :meth:`DataFrame.plot.bar` with ``stacked=True`` where labels on stacked bars with zero-height segments were incorrectly positioned at the base instead of the label position of the previous segment (:issue:`59429`) - Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`) -- Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587` +- Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`) - Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`611 - Bug in :meth:`Series.plot` preventing a line and scatter plot from being aligned (:issue:`6100 - Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`) From 7a30ec7345c50c28c75bc6688700e45e7290811a Mon Sep 17 00:00:00 2001 From: Martin Braquet Date: Wed, 9 Apr 2025 23:56:19 +0700 Subject: [PATCH 09/11] Apply suggestions from code review --- doc/source/whatsnew/v3.0.0.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 65b647e512319..5302912da31fa 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -763,8 +763,8 @@ Plotting - Bug in :meth:`DataFrame.plot.bar` with ``stacked=True`` where labels on stacked bars with zero-height segments were incorrectly positioned at the base instead of the label position of the previous segment (:issue:`59429`) - Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`) - Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`) -- Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`611 -- Bug in :meth:`Series.plot` preventing a line and scatter plot from being aligned (:issue:`6100 +- Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`61161`) +- Bug in :meth:`Series.plot` preventing a line and scatter plot from being aligned (:issue:`61005`) - Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`) Groupby/resample/rolling From 8ed678dd0fd818ade5982bb50e569a3f4c00edea Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 14 Apr 2025 11:34:07 +0700 Subject: [PATCH 10/11] Fix bar and line plot alignment and x-axis visibility in tests Ensure bar and line plots share consistent x-axis tick labels and verify that x-axis limits are adjusted to make all plotted elements visible in `test_bar_line_plot` test. These changes improve the robustness of visual alignment and boundary checks. --- pandas/tests/plotting/test_series.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pandas/tests/plotting/test_series.py b/pandas/tests/plotting/test_series.py index da6c4db550a4e..f47457890abf4 100644 --- a/pandas/tests/plotting/test_series.py +++ b/pandas/tests/plotting/test_series.py @@ -975,13 +975,22 @@ def test_secondary_y_subplot_axis_labels(self): def test_bar_line_plot(self): """ Test that bar and line plots with the same x values are superposed + and that the x limits are set such that the plots are visible. """ # GH61161 index = period_range("2023", periods=3, freq="Y") + years = set(index.year.astype(str)) s = Series([1, 2, 3], index=index) ax = plt.subplot() s.plot(kind="bar", ax=ax) - bar_xticks = ax.get_xticks().tolist() + bar_xticks = [ + label for label in ax.get_xticklabels() if label.get_text() in years + ] s.plot(kind="line", ax=ax, color="r") - line_xticks = ax.get_xticks()[: len(s)].tolist() - assert line_xticks == bar_xticks + line_xticks = [ + label for label in ax.get_xticklabels() if label.get_text() in years + ] + assert bar_xticks == line_xticks + x_limits = ax.get_xlim() + assert x_limits[0] <= bar_xticks[0].get_position()[0] + assert x_limits[1] >= bar_xticks[-1].get_position()[0] From 5cc8c28b3c8e6dcc8162446901027a4447c460dc Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 14 Apr 2025 11:36:22 +0700 Subject: [PATCH 11/11] Ensure bar_xticks length matches index in test_series.py Add an assertion to verify the length of `bar_xticks` aligns with the length of the index. This improves the test's robustness by ensuring the data and ticks remain consistent. --- pandas/tests/plotting/test_series.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pandas/tests/plotting/test_series.py b/pandas/tests/plotting/test_series.py index f47457890abf4..98e70f770896c 100644 --- a/pandas/tests/plotting/test_series.py +++ b/pandas/tests/plotting/test_series.py @@ -990,6 +990,7 @@ def test_bar_line_plot(self): line_xticks = [ label for label in ax.get_xticklabels() if label.get_text() in years ] + assert len(bar_xticks) == len(index) assert bar_xticks == line_xticks x_limits = ax.get_xlim() assert x_limits[0] <= bar_xticks[0].get_position()[0]