diff --git a/news/532.bugfix.md b/news/532.bugfix.md new file mode 100644 index 00000000..958d2919 --- /dev/null +++ b/news/532.bugfix.md @@ -0,0 +1,3 @@ +Fixed `xy.pyplot.fill_between` raising a `TypeError` when passed `datetime`, `datetime64`, or masked date sequences. Standalone datetime fills now render calendar tick labels without requiring a line overlay. +Shared x axes also retain calendar ticks when only one panel contains dates and refresh after date fills are added or removed. Removing a split fill now removes every polygon, including masked and interpolated regions. +Date-axis inference no longer retains the original date container or changes when the caller later mutates it. diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index bdd39697..072c74d9 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1335,6 +1335,30 @@ def _rgba_css(value: Any) -> str: class PolyCollection(Artist): """Generic collection handle used by adapter-composed chart families.""" + def __init__( + self, + axes: Any, + entry: dict[str, Any], + *, + companion_entries: Optional[list[dict[str, Any]]] = None, + ) -> None: + super().__init__(axes, entry) + self._polygon_entries = list(companion_entries or []) + + def _companion_entries(self) -> list[dict[str, Any]]: + return self._polygon_entries + + def remove(self) -> None: + for entry in self._polygon_entries: + self._axes._remove_entry(entry) + self._polygon_entries.clear() + super().remove() + + def set_zorder(self, level: float) -> None: + for entry in self._polygon_entries: + entry["_zorder"] = float(level) + super().set_zorder(level) + def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: _set_entry_clim(self, vmin, vmax) @@ -1361,7 +1385,8 @@ def get_facecolors(self) -> np.ndarray: def set_facecolors(self, colors: Any) -> None: """Set a constant face paint for a filled polygon collection.""" rgba = resolve_rgba_array(colors, 1, "collection facecolors")[0] - self._entry.setdefault("kwargs", {})["color"] = _rgba_css(rgba) + for entry in self._owned_entries(): + entry.setdefault("kwargs", {})["color"] = _rgba_css(rgba) self._touch() set_facecolor = set_facecolors diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 0f8fb74e..4d63a829 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1653,11 +1653,14 @@ def _load_rc_chrome(self) -> None: def _invalidate(self) -> None: host = self._y2_of or self - host._chart = None # Shared render domains are a snapshot from the preceding Figure # materialization pass. Any artist/limit mutation must make the next # pass discover the group's new final view before clipping axlines. - host.__dict__.pop("_shared_render_domains", None) + # Date units are inferred across shared x axes too: a sibling cached + # before a date artist was added or removed must refresh its ticks. + for ax in _SharedAxesGroup("x").get_siblings(host): + ax._chart = None + ax.__dict__.pop("_shared_render_domains", None) if host.figure is not None: host.figure._invalidate() @@ -3177,8 +3180,31 @@ def fill_between( if transform not in (None, "xaxis transform"): raise not_implemented("fill_between(transform=...)") check_unsupported(kwargs, "fill_between()") + + from xy.components import _is_datetime_like + + # Convert temporal x coordinates to epoch milliseconds while preserving + # masked entries and snapshot metadata for datetime-axis detection. + is_datetime_x = False + if np.ma.isMaskedArray(x): + compressed = x.compressed() + if (len(compressed) > 0 and _is_datetime_like(compressed)) or _is_datetime_like(x.data): + is_datetime_x = True + + if is_datetime_x: + # Fill masked points with NaT so unit_converted_values transforms + # dates to float milliseconds without failing on MaskedConstant, + # then reapply the mask to break polygon intervals. + raw_conv = unit_converted_values(x.filled(np.datetime64("NaT", "ms"))) + conv_x = np.ma.array(raw_conv, mask=x.mask) + else: + conv_x = x + else: + is_datetime_x = _is_datetime_like(x) + conv_x = unit_converted_values(x) if is_datetime_x else x + xv, upper, lower = np.broadcast_arrays( - _masked_float(x), + _masked_float(conv_x), _masked_float(y1), _masked_float(y2), ) @@ -3233,24 +3259,23 @@ def fill_between( if step is not None: sx, su = _step_values(sx, su, step) _sx, sl = _step_values(xv[start:end], sl, step) - entries.append( - self._add( - "area", - { - "x": sx, - "y": su, - "kwargs": { - "base": sl, - "color": resolved_color, - "opacity": float(alpha) if alpha is not None else 1.0, - "line_width": float(rcParams["patch.linewidth"]) * self._point_scale(), - "line_opacity": float(alpha) if alpha is not None else 1.0, - "stroke_perimeter": True, - "name": str(label) if label is not None and not entries else None, - }, - }, - ) - ) + area_dict = { + "x": sx, + "y": su, + "kwargs": { + "base": sl, + "color": resolved_color, + "opacity": float(alpha) if alpha is not None else 1.0, + "line_width": float(rcParams["patch.linewidth"]) * self._point_scale(), + "line_opacity": float(alpha) if alpha is not None else 1.0, + "stroke_perimeter": True, + "name": str(label) if label is not None and not entries else None, + }, + } + if is_datetime_x: + area_dict["_datetime_x"] = True + + entries.append(self._add("area", area_dict)) if interpolate: # A single selected point between deselected neighbors spans no # interval, but matplotlib still draws its interpolated wedge. @@ -3279,27 +3304,22 @@ def fill_between( su.append(cross_y) sl.append(cross_y) if len(sx) >= 2: - entries.append( - self._add( - "area", - { - "x": np.asarray(sx), - "y": np.asarray(su), - "kwargs": { - "base": np.asarray(sl), - "color": resolved_color, - "opacity": float(alpha) if alpha is not None else 1.0, - "line_width": float(rcParams["patch.linewidth"]) - * self._point_scale(), - "line_opacity": float(alpha) if alpha is not None else 1.0, - "stroke_perimeter": True, - "name": str(label) - if label is not None and not entries - else None, - }, - }, - ) - ) + interp_dict = { + "x": np.asarray(sx), + "y": np.asarray(su), + "kwargs": { + "base": np.asarray(sl), + "color": resolved_color, + "opacity": float(alpha) if alpha is not None else 1.0, + "line_width": float(rcParams["patch.linewidth"]) * self._point_scale(), + "line_opacity": float(alpha) if alpha is not None else 1.0, + "stroke_perimeter": True, + "name": str(label) if label is not None and not entries else None, + }, + } + if is_datetime_x: + interp_dict["_datetime_x"] = True + entries.append(self._add("area", interp_dict)) if not entries: # Matplotlib still returns a collection handle, but there is no # polygon to retain when no pair of adjacent points is selected. @@ -3318,7 +3338,7 @@ def fill_between( }, } return PolyCollection(self, empty) - return PolyCollection(self, entries[0]) + return PolyCollection(self, entries[0], companion_entries=entries[1:]) def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: """Display a 2-D scalar array or RGB(A) image. @@ -4463,11 +4483,25 @@ def set_position(self, position: Bbox | tuple[float, float, float, float]) -> No self._figure_rect = _parse_bounds(position, "set_position()") self._invalidate() - def _axis_holds_datetimes(self, axis: str) -> bool: + def _axis_holds_datetimes(self, axis: str, *, shared: bool = False) -> bool: from xy.components import _is_datetime_like key = "x" if axis == "x" else "y" - return any(key in entry and _is_datetime_like(entry[key]) for entry in self._entries) + datetime_key = f"_datetime_{key}" + + def _is_datetime_value(val: Any) -> bool: + if np.ma.isMaskedArray(val): + comp = val.compressed() + if len(comp) > 0: + return _is_datetime_like(comp) + return _is_datetime_like(val.data) + return _is_datetime_like(val) + + return any( + entry.get(datetime_key, False) or (key in entry and _is_datetime_value(entry[key])) + for ax in (_SharedAxesGroup(axis).get_siblings(self) if shared else [self]) + for entry in ax._entries + ) def _data_coordinate(self, value: Any, axis: str) -> Optional[float]: """A data-space coordinate as the engine's float — numbers directly, @@ -8731,6 +8765,13 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: y_props = {k: v for k, v in self._axis["y"].items() if v is not None} self._inherit_shared_axis_state("x", x_props) self._inherit_shared_axis_state("y", y_props) + # fill_between retains datetime identity after converting its geometry + # to f64 milliseconds. Carry it into the rendered axis (dossier §16), + # without overriding explicit scales or persisting an inferred type. + if self._scale_specs["x"]["name"] == "linear" and self._axis_holds_datetimes( + "x", shared=True + ): + x_props.setdefault("type_", "time") rotation_source = self._shared_ticker_source("x") if ( rotation_source._tick_rotation_modes.get("x") == "xtick" diff --git a/spec/matplotlib/compat-matrix.md b/spec/matplotlib/compat-matrix.md index b0ff74b1..83ffbe52 100644 --- a/spec/matplotlib/compat-matrix.md +++ b/spec/matplotlib/compat-matrix.md @@ -8,8 +8,8 @@ Corpus links are executable examples and are checked for every supported method. | Family | Level | Methods | Executable corpus | |---|---|---:|---:| -| Basic | equivalent semantics | 22 | 48 | -| Spans | exact geometry | 5 | 4 | +| Basic | equivalent semantics | 22 | 51 | +| Spans | exact geometry | 5 | 5 | | Spectral | equivalent semantics | 9 | 2 | | Statistics | equivalent semantics | 5 | 2 | | Binned | exact geometry | 4 | 7 | @@ -32,7 +32,7 @@ Approximation level: **equivalent semantics**. - `loglog` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `semilogx` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) - `semilogy` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) -- `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py) +- `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`56_fill_between_datetime.py`](../../tests/pyplot/corpus/56_fill_between_datetime.py), [`57_fill_between_shared_dates.py`](../../tests/pyplot/corpus/57_fill_between_shared_dates.py), [`58_fill_between_remove_dates.py`](../../tests/pyplot/corpus/58_fill_between_remove_dates.py) - `fill_betweenx` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) - `barh` — [`16_barh.py`](../../tests/pyplot/corpus/16_barh.py) @@ -52,7 +52,7 @@ Approximation level: **equivalent semantics**. Approximation level: **exact geometry**. -- `axhline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py) +- `axhline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`57_fill_between_shared_dates.py`](../../tests/pyplot/corpus/57_fill_between_shared_dates.py) - `axhspan` — [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py) - `axvline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py) - `axvspan` — [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py) diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index d09af806..2cc019a8 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -57,6 +57,14 @@ and asserts every snapshot method exists on its `Axes`. The dev revision recorded in the snapshot is informational: CI no longer compares the snapshot against an upstream Matplotlib checkout. +The dual-engine runner executes every corpus case in a fresh process. Its +reference harness only normalizes renderer-specific HTML export and xy's +dependency-free `triangles=` shorthand into Matplotlib's equivalent +`Triangulation` positional form; chart data and plotting options are unchanged. +PNG comparison captures the current figure at the end of each case. Examples +that need independent visual coverage belong in separate corpus files; the +datetime fill, shared-date axes, and removal scenarios each have their own case. + **The compat completion claim is gallery-defined.** The permanent contract records the 507-source Matplotlib 3.11.1 documentation archive, excludes its 48 three-dimensional examples, and uses Matplotlib 3.11.0 as the pinned @@ -110,7 +118,7 @@ extended environment, thresholds, and completed report contract. | `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; stacked density normalizes the combined weighted area once (including unequal bins and either cumulative direction), matching Matplotlib 3.11; bar, step, and stepfilled families render in both vertical and horizontal orientations; unfilled step outlines connect their top envelope to zero or the previous stack at both endpoints | | `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel. `hist2d` delegates rendering to the pseudocolor-mesh path for both uniform and non-uniform bins, supports linear and logarithmic normalization, defaults to fully opaque cells, and retains the original count domain for logarithmic mappables. Its view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges. Arbitrary custom normalization and `colorizer` remain unsupported. Hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, dashed line-component styles, and component colors/widths/alpha. Default boxes are unfilled outlines and return Matplotlib-shaped per-group component handles (two whiskers/caps and one box/median/flier handle per group). `patch_artist=True` emits mutable filled polygon boxes; statistics labels become category tick labels, while scalar or per-box legend labels bind to boxes for patch plots and medians otherwise. Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free mutable body per group, cycle face and line color sequences, preserve color-alpha pairs, and support quantiles and low/high sides. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | -| `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | +| `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly. For `fill_between`, temporal x coordinates convert to canonical epoch milliseconds and datetime identity is captured once as a boolean, without retaining the caller’s original coordinate container or depending on later mutations; nonempty standalone fills retain a time axis with automatic calendar ticks in HTML, PNG, and SVG, including stepped and interpolated fills. Its collection owns every polygon split by masks, `where`, or interpolation, so removal, visibility, alpha, face color, and zorder apply to the whole fill. Explicit non-linear scales take precedence, and removing the date fill does not leave a numeric axis forced to time | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `psd`, `csd`, `cohere`, `specgram` | Native real-valued Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Callable windows/detrending, independent `pad_to`, explicit sides/frequency scaling, and complex/two-sided inputs remain unsupported and fail loudly instead of silently changing the signal; completing these is tracked acceptance debt for `statistics/psd_demo.py` | | `imshow` / `matshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. `plt.matshow(A)` creates a dedicated bounded-`figaspect` figure and top-ticked axes like Matplotlib; `fignum=0` reuses the current axes and an existing numbered figure is not resized. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | @@ -130,7 +138,7 @@ extended environment, thresholds, and completed report contract. | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | -| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. Shared x axes infer datetime units from any member of their all/row/column group, including panels with no x data; adding or removing date artists refreshes cached siblings without persisting an inferred axis type. Unrelated groups retain their own units. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | | `Figure.subfigures` / `SubFigure.subfigures` / `add_subfigure` | Native subfigures may nest and own their axes directly while the root `Figure.axes` retains global draw order. `Axes.get_figure(root=True)` resolves the root; `SubFigure.get_figure(root=False)` resolves its direct parent. The measured ownership tree gives every figure, subfigure, axes, inset, twin, colorbar axes/chrome, and figure-text node a root-normalized viewport and clip; HTML, SVG, and native PNG consume one immutable resolved snapshot per export. | | `subplot(projection="polar")`; `add_subplot(..., polar=True)`; `axes(projection="polar")`; `subplots(subplot_kw={"projection": "polar"})` | Ordinary `plot`, `scatter`, `fill`, `bar`, heatmap/image, contour, and error-bar calls render through the core polar coordinate system in HTML, PNG, and SVG. The PolarAxes controls `set_theta_zero_location`, `set_theta_direction`, `set_theta_offset`, `set_thetagrids`, `set_thetamin`/`set_thetamax` (degrees), `set_rlim`, `set_rticks`, `set_rorigin`, and their theta/r limit accessors route into the same angular/radial axes. Categorical θ and log/symlog radial scales use that core transform as well. Polar `axhline`/`axvline` and span geometry, LOD, facets/animation, angular navigation/selection, and the stateful `plt.polar`/`plt.thetagrids`/`plt.rgrids` convenience wrappers remain outside this surface and fail or remain absent rather than drawing a Cartesian approximation. **Silently dropped on a polar Axes:** minor ticks and their style (`minorticks_on`, `minor` `tick_params`, `set_minor_locator`), tick-label horizontal alignment (`tick_params(ha=)`), and the tick-label collision strategies — no renderer draws minor rings or spokes, and rim labels have no edge-relative collision pass or anchor (`spec/design/polar-axes.md` §9). They are dropped rather than refused because every Axes carries an rcParam-derived `minor_style`, so refusing would break the projection over a default nobody authored; a hand-authored `xy.theta_axis`/`xy.r_axis` refuses them instead. | | `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | diff --git a/tests/pyplot/corpus/56_fill_between_datetime.py b/tests/pyplot/corpus/56_fill_between_datetime.py new file mode 100644 index 00000000..125a9b12 --- /dev/null +++ b/tests/pyplot/corpus/56_fill_between_datetime.py @@ -0,0 +1,18 @@ +"""Standalone temporal fills retain date ticks without a line overlay.""" + +from datetime import datetime, timedelta + +import numpy as np + +import xy.pyplot as plt + +dates = [datetime(2026, 1, 1) + timedelta(days=i) for i in range(7)] +upper = np.array([3.0, 4.0, 5.0, 4.0, 6.0, 5.0, 7.0]) +fig, axes = plt.subplots(1, 3, figsize=(14, 4)) +axes[0].fill_between(dates, upper, upper - 2.0, alpha=0.35) +axes[0].set_title("Python datetime band") +axes[1].fill_between(np.asarray(dates, dtype="datetime64[D]"), upper, step="mid", alpha=0.35) +axes[1].set_title("NumPy datetime steps") +masked_dates = np.ma.masked_array(dates, mask=[False, False, False, True, False, False, False]) +axes[2].fill_between(masked_dates, upper, alpha=0.35) +axes[2].set_title("Masked datetime gaps") diff --git a/tests/pyplot/corpus/57_fill_between_shared_dates.py b/tests/pyplot/corpus/57_fill_between_shared_dates.py new file mode 100644 index 00000000..b0e35cc3 --- /dev/null +++ b/tests/pyplot/corpus/57_fill_between_shared_dates.py @@ -0,0 +1,17 @@ +"""Shared date units refresh panels cached before a temporal fill was added.""" + +from datetime import datetime, timedelta + +import numpy as np + +import xy.pyplot as plt + +dates = [datetime(2026, 1, 1) + timedelta(days=i) for i in range(7)] +upper = np.array([3.0, 4.0, 5.0, 4.0, 6.0, 5.0, 7.0]) +masked_dates = np.ma.masked_array(dates, mask=[False, False, False, True, False, False, False]) +fig, axes = plt.subplots(2, 1, sharex=True, figsize=(7, 6)) +axes[1].axhline(1, color="purple") +# Render before adding dates to exercise shared-axis cache refresh. +fig.canvas.draw() +axes[0].fill_between(masked_dates, upper, alpha=0.35) +axes[0].set_title("Shared dates: labels on the panel without date data") diff --git a/tests/pyplot/corpus/58_fill_between_remove_dates.py b/tests/pyplot/corpus/58_fill_between_remove_dates.py new file mode 100644 index 00000000..9b02c8ca --- /dev/null +++ b/tests/pyplot/corpus/58_fill_between_remove_dates.py @@ -0,0 +1,20 @@ +"""Removing a temporal fill removes every region before numeric replacement.""" + +from datetime import datetime, timedelta + +import numpy as np + +import xy.pyplot as plt + +dates = [datetime(2026, 1, 1) + timedelta(days=i) for i in range(7)] +upper = np.array([3.0, 4.0, 5.0, 4.0, 6.0, 5.0, 7.0]) +masked_dates = np.ma.masked_array(dates, mask=[False, False, False, True, False, False, False]) +fig, ax = plt.subplots() +removed = ax.fill_between(masked_dates, upper) +fig.canvas.draw() +removed.remove() +ax.fill_between([0, 1, 2], [1, 2, 1], alpha=0.35) +# Matplotlib retains removed artists' data limits until explicitly reset. +ax.set_xlim(-0.1, 2.1) +ax.set_ylim(0, 3) +ax.set_title("All date regions removed: numeric replacement") diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 24eda649..904546f0 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +from datetime import datetime from io import BytesIO from pathlib import Path +from typing import Any, cast import numpy as np import pytest @@ -110,6 +112,159 @@ def test_fill_between_uses_a_faint_full_perimeter_not_an_opaque_lower_line() -> assert trace.style["line_opacity"] == pytest.approx(0.2) +def _area_x(ax: plt.Axes) -> np.ndarray: + areas = [entry for entry in ax._entries if entry["kind"] == "area"] + assert len(areas) == 1 + return np.asarray(areas[0]["x"], dtype=np.float64) + + +def test_fill_between_accepts_python_datetimes() -> None: + _fig, ax = plt.subplots() + dates = [ + datetime(2026, 1, 1, 12), + datetime(2026, 1, 2, 12), + datetime(2026, 1, 3, 12), + ] + + ax.fill_between(cast(Any, dates), [1.0, 2.0, 1.5]) + + expected = np.asarray(dates, dtype="datetime64[ms]").astype(np.float64) + np.testing.assert_array_equal(_area_x(ax), expected) + + +def test_fill_between_accepts_numpy_datetime64() -> None: + _fig, ax = plt.subplots() + dates = np.array( + ["2026-01-01", "2026-01-02", "2026-01-03"], + dtype="datetime64[D]", + ) + + ax.fill_between(dates, [1.0, 2.0, 1.5]) + + expected = dates.astype("datetime64[ms]").astype(np.float64) + np.testing.assert_array_equal(_area_x(ax), expected) + + +def test_fill_between_masked_datetime_breaks_polygon() -> None: + _fig, ax = plt.subplots() + dates = np.ma.masked_array( + [ + datetime(2026, 1, 1), + datetime(2026, 1, 2), + datetime(2026, 1, 3), + datetime(2026, 1, 4), + datetime(2026, 1, 5), + ], + mask=[False, False, True, False, False], + ) + + ax.fill_between(cast(Any, dates), [1.0, 2.0, 1.5, 2.5, 3.0]) + + areas = [entry for entry in ax._entries if entry["kind"] == "area"] + assert len(areas) == 2 + + expected_1 = np.asarray(dates.data[:2], dtype="datetime64[ms]").astype(np.float64) + expected_2 = np.asarray(dates.data[3:], dtype="datetime64[ms]").astype(np.float64) + np.testing.assert_array_equal(areas[0]["x"], expected_1) + np.testing.assert_array_equal(areas[1]["x"], expected_2) + + +def test_fill_between_masked_leading_datetime() -> None: + _fig, ax = plt.subplots() + dates = np.ma.masked_array( + [ + datetime(2026, 1, 1), + datetime(2026, 1, 2), + datetime(2026, 1, 3), + ], + mask=[True, False, False], + ) + + ax.fill_between(cast(Any, dates), [1.0, 2.0, 3.0]) + + areas = [entry for entry in ax._entries if entry["kind"] == "area"] + assert len(areas) == 1 + expected = np.asarray(dates.data[1:], dtype="datetime64[ms]").astype(np.float64) + np.testing.assert_array_equal(areas[0]["x"], expected) + + +def test_fill_between_nat_breaks_polygon() -> None: + _fig, ax = plt.subplots() + dates = np.array( + ["2026-01-01", "2026-01-02", "NaT", "2026-01-04", "2026-01-05"], + dtype="datetime64[D]", + ) + + ax.fill_between(cast(Any, dates), [1.0, 2.0, 1.5, 2.5, 3.0]) + + areas = [entry for entry in ax._entries if entry["kind"] == "area"] + assert len(areas) == 2 + + expected_1 = dates[:2].astype("datetime64[ms]").astype(np.float64) + expected_2 = dates[3:].astype("datetime64[ms]").astype(np.float64) + np.testing.assert_array_equal(areas[0]["x"], expected_1) + np.testing.assert_array_equal(areas[1]["x"], expected_2) + + +def test_fill_between_masked_integers_breaks_polygon() -> None: + _fig, ax = plt.subplots() + x = np.ma.masked_array([1, 2, 3, 4, 5], mask=[False, False, True, False, False]) + + ax.fill_between(x, [1.0, 2.0, 1.5, 2.5, 3.0]) + + areas = [entry for entry in ax._entries if entry["kind"] == "area"] + assert len(areas) == 2 + + np.testing.assert_array_equal(areas[0]["x"], np.array([1.0, 2.0])) + np.testing.assert_array_equal(areas[1]["x"], np.array([4.0, 5.0])) + + +@pytest.mark.parametrize("date_kind", ["python", "numpy", "masked"]) +@pytest.mark.parametrize("fill_kind", ["ordinary", "step", "interpolate"]) +def test_fill_between_standalone_sets_datetime_axis(date_kind: str, fill_kind: str) -> None: + fig, ax = plt.subplots() + dates: Any = [datetime(2026, 1, day) for day in range(1, 6)] + if date_kind == "numpy": + dates = np.asarray(dates, dtype="datetime64[D]") + elif date_kind == "masked": + dates = np.ma.masked_array(dates, mask=[True, False, False, False, False]) + options: dict[str, Any] = {} + if fill_kind == "step": + options["step"] = "mid" + elif fill_kind == "interpolate": + options.update(where=[False, False, True, False, False], interpolate=True) + ax.fill_between(dates, [-1.0, -1.0, 1.0, -1.0, -1.0], **options) + + engine = ax._build_chart(*fig._panel_px()).figure() + payload, _ = engine.build_payload() + assert payload["x_axis"]["kind"] == "time" + assert payload["y_axis"]["kind"] == "linear" + assert "1.8e12" not in engine.to_svg() + + +@pytest.mark.parametrize("scale", ["log", "symlog"]) +def test_fill_between_datetime_respects_explicit_scale(scale: str) -> None: + _fig, ax = plt.subplots() + ax.fill_between(cast(Any, [datetime(2026, 1, 1), datetime(2026, 1, 2)]), [1.0, 2.0]) + ax.set_xscale(scale) + payload, _ = ax._build_chart(640, 480).figure().build_payload() + assert payload["x_axis"]["kind"] != "time" + ax.set_xscale("linear") + payload, _ = ax._build_chart(640, 480).figure().build_payload() + assert payload["x_axis"]["kind"] == "time" + + +def test_removed_datetime_fill_does_not_leave_numeric_axis_as_time() -> None: + _fig, ax = plt.subplots() + fill = ax.fill_between(cast(Any, [datetime(2026, 1, 1), datetime(2026, 1, 2)]), [1.0, 2.0]) + payload, _ = ax._build_chart(640, 480).figure().build_payload() + assert payload["x_axis"]["kind"] == "time" + fill.remove() + ax.fill_between([0.0, 1.0], [1.0, 2.0]) + payload, _ = ax._build_chart(640, 480).figure().build_payload() + assert payload["x_axis"]["kind"] == "linear" + + def test_fill_betweenx_static_export_joins_dense_triangle_strip(monkeypatch) -> None: from xy import _raster diff --git a/tests/pyplot/test_fill_between_shared_dates.py b/tests/pyplot/test_fill_between_shared_dates.py new file mode 100644 index 00000000..3c666dae --- /dev/null +++ b/tests/pyplot/test_fill_between_shared_dates.py @@ -0,0 +1,172 @@ +"""A split fill is one artist, and date units belong to a shared axis group.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + yield + plt.close("all") + + +@pytest.mark.parametrize("temporal", [False, True]) +@pytest.mark.parametrize("split", ["mask", "where", "interpolate"]) +def test_split_fill_removal_removes_every_polygon(temporal: bool, split: str) -> None: + fig, ax = plt.subplots() + x = np.arange("2026-01-01", "2026-01-08", dtype="datetime64[D]") if temporal else np.arange(7) + options: dict[str, Any] = {} + y = np.ones(7) + if split == "mask": + x = np.ma.array(x, mask=[False, False, False, True, False, False, False]) + elif split == "where": + options["where"] = [True, True, True, False, True, True, True] + else: + y = np.array([-1, 1, -1, -1, -1, 1, -1]) + options.update(where=y > 0, interpolate=True) + fill = ax.fill_between(x, y, **options) + other = ax.axhline(0.5) + assert len([trace for trace in fig._charts()[0].figure().traces if trace.kind == "area"]) == 2 + + fill.remove() + assert fill not in ax.collections + assert ax._entries == [other._entry] + ax.fill_between([0.0, 1.0], [1.0, 2.0]) + engine = fig._charts()[0].figure() + payload, _ = engine.build_payload() + assert payload["x_axis"]["kind"] == "linear" + assert payload["x_axis"]["range"] == pytest.approx([-0.05, 1.05]) + assert len([trace for trace in engine.traces if trace.kind == "area"]) == 1 + + +def test_split_fill_mutations_apply_to_the_whole_collection() -> None: + fig, ax = plt.subplots() + fill = ax.fill_between(range(5), [1] * 5, where=[True, True, False, True, True]) + fill.set_visible(False) + assert all(trace.style["opacity"] == 0 for trace in fig._charts()[0].figure().traces) + fill.set_alpha(0.4) + fill.set_visible(True) + fill.set_facecolor("red") + fill.set_zorder(3) + traces = fig._charts()[0].figure().traces + assert len(traces) == 2 + for trace in traces: + assert trace.style["opacity"] == 0.4 + assert trace.style["color"] == "rgba(255,0,0,1)" + assert all(entry["_zorder"] == 3 for entry in ax._entries) + + +@pytest.mark.parametrize("sharex", [False, "all", "row", "col"]) +@pytest.mark.parametrize("source", [0, 3]) +def test_shared_date_axis_refreshes_after_add_and_remove(sharex: Any, source: int) -> None: + fig, axes = plt.subplots(2, 2, sharex=sharex) + for ax in axes.flat: + ax.axhline(0.5) + before = fig._charts() + assert all(chart.figure().build_payload()[0]["x_axis"]["kind"] == "linear" for chart in before) + group = ( + next(group for group in fig._share_groups(sharex, 4) if source in group) + if sharex + else [source] + ) + fill = axes.flat[source].fill_between( + np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"), + [1] * 5, + where=[True, True, False, True, True], + ) + charts = fig._charts() + payloads = [chart.figure().build_payload()[0] for chart in charts] + for index, (chart, payload) in enumerate(zip(charts, payloads, strict=True)): + assert payload["x_axis"]["kind"] == ("time" if index in group else "linear") + if index in group: + assert payload["x_axis"]["range"] == payloads[source]["x_axis"]["range"] + else: + assert chart is before[index] + assert "1.8e12" not in "".join(chart.figure().to_svg() for chart in charts) + + fill.remove() + axes.flat[source].fill_between([0.0, 1.0], [1.0, 2.0]) + for chart in fig._charts(): + payload, _ = chart.figure().build_payload() + assert payload["x_axis"]["kind"] == "linear" + assert max(payload["x_axis"]["range"]) < 2 + + +@pytest.mark.parametrize("scale", ["log", "symlog"]) +def test_shared_date_inference_respects_explicit_scales(scale: str) -> None: + fig, axes = plt.subplots(2, 1, sharex=True) + axes[1].fill_between(np.array(["2026-01-01", "2026-01-02"], dtype="datetime64[D]"), [1, 2]) + for ax in axes: + ax.set_xscale(scale) + assert all( + chart.figure().build_payload()[0]["x_axis"]["kind"] != "time" for chart in fig._charts() + ) + for ax in axes: + ax.set_xscale("linear") + assert all( + chart.figure().build_payload()[0]["x_axis"]["kind"] == "time" for chart in fig._charts() + ) + + +def test_subplot_shared_property_store_inherits_date_units() -> None: + fig = plt.figure() + top = fig.add_subplot(211) + bottom = fig.add_subplot(212, sharex=top) + fig._charts() + fill = bottom.fill_between( + np.array(["2026-01-01", "2026-01-02"], dtype="datetime64[D]"), [1, 2] + ) + assert all( + chart.figure().build_payload()[0]["x_axis"]["kind"] == "time" for chart in fig._charts() + ) + fill.remove() + assert all( + chart.figure().build_payload()[0]["x_axis"]["kind"] == "linear" for chart in fig._charts() + ) + + +@pytest.mark.parametrize("split", ["ordinary", "where", "interpolate"]) +def test_datetime_fill_axis_is_independent_of_later_input_mutation(split: str) -> None: + from datetime import datetime + + dates: list[Any] = [datetime(2026, 1, day) for day in range(1, 8)] + expected = np.asarray(dates, dtype="datetime64[ms]").astype(np.float64) + options: dict[str, Any] = {} + values = np.ones(7) + if split == "where": + options["where"] = [True, True, True, False, True, True, True] + elif split == "interpolate": + values = np.array([-1, 1, -1, -1, -1, 1, -1]) + options.update(where=values > 0, interpolate=True) + fig, axes = plt.subplots(2, 1, sharex=True) + fill = axes[0].fill_between(dates, values, **options) + stored_x = [entry["x"].copy() for entry in axes[0]._entries] + dates[:] = list(range(7)) + for chart in fig._charts(): + assert chart.figure().build_payload()[0]["x_axis"]["kind"] == "time" + for entry, original in zip(axes[0]._entries, stored_x, strict=True): + np.testing.assert_array_equal(entry["x"], original) + assert np.min(entry["x"]) >= expected[0] + fill.remove() + assert not axes[0]._axis_holds_datetimes("x") + + +def test_datetime_fill_does_not_keep_the_source_array_alive() -> None: + import gc + import weakref + + dates = np.arange("2026-01-01", "2026-01-08", dtype="datetime64[D]") + reference = weakref.ref(dates) + fig, ax = plt.subplots() + ax.fill_between(dates, [1] * 7, where=[True, True, True, False, True, True, True]) + del dates + gc.collect() + assert reference() is None + assert fig._charts()[0].figure().build_payload()[0]["x_axis"]["kind"] == "time"