From 8848f58621204beae1285334311e5c3dac5aac58 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:36:06 -0700 Subject: [PATCH 1/7] Fix Matplotlib statistical numeric semantics --- python/xy/_native.py | 4 +- python/xy/pyplot/_axes.py | 74 +++++++-- spec/design/rust-engine.md | 2 +- spec/matplotlib/compat-changelog.md | 10 ++ spec/matplotlib/compat.md | 3 +- src/kernels.rs | 18 +- .../test_statistics_numeric_regressions.py | 157 ++++++++++++++++++ 7 files changed, 240 insertions(+), 28 deletions(-) create mode 100644 tests/pyplot/test_statistics_numeric_regressions.py diff --git a/python/xy/_native.py b/python/xy/_native.py index deb5b00e..d0ec4fb5 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -1565,7 +1565,7 @@ def welch_spectra( npt.NDArray[np.float64], npt.NDArray[np.float64], ]: - """Native Welch auto and optional complex cross spectra.""" + """Native non-detrended Welch auto and optional complex cross spectra.""" x_values = _as_f64(x, "x") y_values = None if y is None else _as_f64(y, "y") if y_values is not None and len(y_values) != len(x_values): @@ -1597,7 +1597,7 @@ def spectrogram( noverlap: int = 128, sample_rate: float = 2.0, ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: - """Native time-major Welch spectrogram.""" + """Native non-detrended time-major Welch spectrogram.""" values = _as_f64(data, "data") nfft = _bounded_positive_int(nfft, "nfft", max_value=65_536) noverlap = operator.index(noverlap) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..09e848d9 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2037,21 +2037,59 @@ def hist( weight_sets = [np.asarray(weights, dtype=np.float64)] else: weight_sets = [np.asarray(value, dtype=np.float64) for value in weights] - counts = [ - np.histogram(values, bins=edges, weights=w, density=density)[0].astype(np.float64) - for values, w in zip(datasets, weight_sets, strict=True) - ] + stacked = stacked or histtype == "barstacked" + # Matplotlib bins stacked inputs as raw (possibly weighted) mass, + # cumulatively stacks the datasets, and only then normalizes the top + # envelope. Normalizing each input independently would give every + # dataset unit area and is especially wrong for unequal bin widths. + counts_array = np.vstack( + [ + np.histogram( + values, + bins=edges, + weights=w, + density=density and not stacked, + )[0].astype(np.float64) + for values, w in zip(datasets, weight_sets, strict=True) + ] + ) + if stacked: + tops = np.cumsum(counts_array, axis=0) + if density: + with np.errstate(divide="ignore", invalid="ignore"): + tops = (tops / np.diff(edges)) / tops[-1].sum() + counts_array = np.diff( + np.vstack((np.zeros((1, counts_array.shape[1]), dtype=np.float64), tops)), + axis=0, + ) + counts = list(counts_array) if cumulative: reverse = isinstance(cumulative, (int, float, np.number)) and cumulative < 0 - counts = [ - ( - np.cumsum((values * np.diff(edges) if density else values)[::-1])[::-1] - if reverse - else np.cumsum(values * np.diff(edges) if density else values) + cumulative_tops = np.cumsum(counts_array, axis=0) if stacked else counts_array + cumulative_tops = np.asarray( + [ + ( + np.cumsum((values * np.diff(edges) if density else values)[::-1])[::-1] + if reverse + else np.cumsum(values * np.diff(edges) if density else values) + ) + for values in cumulative_tops + ] + ) + counts_array = ( + np.diff( + np.vstack( + ( + np.zeros((1, cumulative_tops.shape[1]), dtype=np.float64), + cumulative_tops, + ) + ), + axis=0, ) - for values in counts - ] - stacked = stacked or histtype == "barstacked" + if stacked + else cumulative_tops + ) + counts = list(counts_array) def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[Any]: if value is None: @@ -2097,6 +2135,14 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ else (1.0 if (stacked or len(datasets) == 1) else 0.8) ) width = binwidths * rel_width / (1 if stacked else len(datasets)) + # Keep the common uniform-bin entry scalar. Besides avoiding a + # redundant per-bin column, explicit legends expect one swatch width; + # genuinely unequal bins retain their exact width array. + entry_width: float | np.ndarray = ( + float(width[0]) + if len(width) and np.allclose(width, width[0], rtol=1e-12, atol=0.0) + else width + ) for index, values in enumerate(counts): positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width current_base = base.copy() if stacked else np.zeros_like(values) @@ -2139,7 +2185,7 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ "y": values, "kwargs": { "base": current_base, - "width": width, + "width": entry_width, "orientation": "horizontal", "color": resolved_color, "opacity": 1.0 if alpha is None else float(alpha), @@ -2218,7 +2264,7 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ "y": values, "kwargs": { "base": current_base, - "width": width, + "width": entry_width, "orientation": orientation, "color": "transparent" if fill is False else resolved_color, "opacity": 1.0 if alpha is None else float(alpha), diff --git a/spec/design/rust-engine.md b/spec/design/rust-engine.md index cb4a2c30..bdaeb078 100644 --- a/spec/design/rust-engine.md +++ b/spec/design/rust-engine.md @@ -29,7 +29,7 @@ clear ImportError when it can't load, with no pure-Python fallback. | fixed-width string/bytes/bool factorization | Rust (ABI v36) | correct — compact palettes use a bounded L1-resident codebook with full-record collision checks and emit exact counts; U1 uses a direct Unicode-scalar table with endian support; ≥512k rows probe a prefix then encode disjoint chunks in parallel, merging late labels by canonical first-row order before any retry; Python sees only unique labels and retains display-label ordering policy | | stable animation-key encoding for homogeneous fixed-width string/bytes/bool/integer/float columns | Rust (ABI v43) | correct — one borrowed row scan emits the two caller-owned u32 identity planes and reports the first duplicate pair; f16/f32 widen exactly to the f64 token contract and non-native arrays carry an explicit endian flag; Python validates shape/length, unwraps `to_numpy` columns and homogeneous object storage so a DataFrame key column routes at all, bounds padded temporaries for skewed string/bytes sequences, assembles exact public errors, and retains the reference encoder for mixed objects, trailing-NUL Python sequences, dates, and non-finite row diagnostics. Declined *data* (status 1) falls back; an out-of-contract *layout* (status 4) raises, so the two cannot be confused into a silent slow path | | static display-list raster, row-banded polyline/point/segment paint, batched fill+stroke triangle meshes, affine scatter projection plus typed color/size resolution, density/heatmap colormap and sampling | Rust (ABI v36) | correct — commands borrow f32/u8 payload or canonical spans synchronously; compact stratified sampling reuses factorization counts; batched/banded output is byte-identical | -| signal processing: `xy_rfft`, `xy_welch_spectra`, `xy_spectrogram` | Rust (ABI v36) | correct — O(N) transforms over sample columns; window/segment policy stays in Python | +| signal processing: `xy_rfft`, `xy_welch_spectra`, `xy_spectrogram` | Rust (ABI v36) | correct — O(N) transforms over sample columns; Hann windowing and segment traversal are native, with Matplotlib-compatible `detrend_none` defaults; explicit pyplot detrending modes fail loudly until the kernel can select them deliberately | | geometry/triangulation: `xy_delaunay_triangles`, `xy_polygon_triangles`, `xy_marching_squares`, `xy_marching_triangles`, `xy_streamlines`, `xy_vector_segments`, `xy_quad_mesh_triangles`, `xy_sector_triangles`, `xy_indexed_triangles`, `xy_triangle_edges` | Rust (ABI v36) | correct — output is screen-bounded index/vertex buffers; level choice and styling stay in Python | | statistics: `xy_correlation`, `xy_weighted_ecdf`, `xy_histogram2d`, `xy_stacked_bounds` | Rust (ABI v36) | correct — row-scan reductions; binning policy and labels stay in Python. Unweighted `xy_histogram2d` fans out with per-worker u64 grids (integer merge, thread-count invariant); the weighted case stays serial because f64 accumulation order must not vary with core count (§21) | | style/text helpers: `xy_css_check` (`css.rs`), `xy_svg_poly_path` (`svg.rs`) | Rust (ABI v36) | correct by a different rule — not O(rows) but O(points)/per-value on the export and validation paths, where per-item Python object churn dominates; error *messages* still assembled in Python | diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..7c0623e5 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,16 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Histogram and spectral numeric semantics — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `hist(density=True, stacked=True)` now bins raw per-dataset mass, stacks it, + and normalizes the combined top envelope once. Unequal bin widths, weights, + and both cumulative directions match Matplotlib 3.11.1 numeric outputs. +- The native Welch paths behind `psd`, `csd`, `cohere`, and `specgram` no + longer subtract each segment mean by default. Their omitted/`None` + `detrend` behavior is Matplotlib's `detrend_none`; unsupported explicit + detrending modes continue to fail loudly at the pyplot boundary. + ## Vector-field gallery corrections — 2026-07-24 - `quiver(units=...)` now converts Matplotlib's width-unit vocabulary without diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..0e8a947a 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -53,11 +53,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | +| `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 | | `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; 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. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `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`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `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 | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | +| `psd`, `csd`, `cohere`, `specgram` | Native Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Explicit detrending modes remain unsupported and fail loudly instead of silently changing the signal | | `imshow` / `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. 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 | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | diff --git a/src/kernels.rs b/src/kernels.rs index 2b67e232..5474ba8f 100644 --- a/src/kernels.rs +++ b/src/kernels.rs @@ -1508,21 +1508,19 @@ fn spectral_window(nfft: usize) -> Vec { fn windowed_fft(data: &[f64], start: usize, nfft: usize, window: &[f64]) -> (Vec, Vec) { let available = data.len().saturating_sub(start).min(nfft); - let mean = if available == 0 { - 0.0 - } else { - data[start..start + available].iter().sum::() / available as f64 - }; let mut real = vec![0.0; nfft]; let mut imag = vec![0.0; nfft]; for index in 0..available { - real[index] = (data[start + index] - mean) * window[index]; + // Matplotlib's psd/csd/cohere/specgram default is detrend_none. + // Explicit detrending modes are rejected by the pyplot shim until + // the native kernel can select them deliberately. + real[index] = data[start + index] * window[index]; } fft_in_place(&mut real, &mut imag); (real, imag) } -/// Windowed real FFT returning the nonnegative-frequency half spectrum. +/// Hann-windowed, non-detrended real FFT returning the nonnegative half spectrum. pub fn rfft_into( data: &[f64], nfft: usize, @@ -1565,8 +1563,8 @@ fn spectral_segment_count(len: usize, nfft: usize, noverlap: usize) -> Option None: + plt.close("all") + + +@pytest.mark.parametrize( + ("cumulative", "expected"), + [ + ( + False, + [ + [1 / 13, 8 / 39, 16 / 117], + [2 / 13, 6 / 13, 16 / 39], + ], + ), + ( + True, + [ + [1 / 13, 7 / 39, 5 / 13], + [2 / 13, 5 / 13, 1.0], + ], + ), + ( + -1, + [ + [5 / 13, 4 / 13, 8 / 39], + [1.0, 11 / 13, 8 / 13], + ], + ), + ], +) +def test_stacked_weighted_density_matches_matplotlib_311( + cumulative: bool | int, expected: list[list[float]] +) -> None: + datasets = [ + np.array([0.2, 0.4, 1.1, 2.1]), + np.array([0.3, 1.2, 1.8, 2.4]), + ] + weights = [ + np.array([1.0, 2.0, 4.0, 8.0]), + np.array([3.0, 5.0, 7.0, 9.0]), + ] + edges = np.array([0.0, 1.0, 1.5, 3.0]) + _fig, ax = plt.subplots() + + counts, returned_edges, containers = ax.hist( + datasets, + bins=edges, + weights=weights, + density=True, + stacked=True, + cumulative=cumulative, + ) + + np.testing.assert_array_equal(returned_edges, edges) + np.testing.assert_allclose(counts, expected, rtol=1e-14, atol=0.0) + np.testing.assert_allclose(containers[0].datavalues, counts[0], rtol=1e-14, atol=0.0) + np.testing.assert_allclose( + containers[1].datavalues, counts[1] - counts[0], rtol=1e-14, atol=0.0 + ) + if not cumulative: + assert np.sum(counts[-1] * np.diff(edges)) == pytest.approx(1.0) + + +def test_uniform_multihist_keeps_a_scalar_swatch_width_for_legend() -> None: + values = np.arange(24.0).reshape(8, 3) + _fig, ax = plt.subplots() + + ax.hist(values, bins=4, label=["a", "b", "c"]) + + assert all(np.isscalar(entry["kwargs"]["width"]) for entry in ax._entries) + assert ax.legend() is not None + + +def test_spectral_defaults_match_matplotlib_311_detrend_none() -> None: + values = np.arange(32.0) + 10.0 + paired = values * 2.0 + 3.0 + _fig, axes = plt.subplots(2, 2) + + pxx, psd_frequency = axes[0, 0].psd(values, NFFT=8, Fs=8, noverlap=4) + cross, csd_frequency = axes[0, 1].csd(values, paired, NFFT=8, Fs=8, noverlap=4) + coherence, coherence_frequency = axes[1, 0].cohere(values, paired, NFFT=8, Fs=8, noverlap=4) + spectrum, specgram_frequency, time, _image = axes[1, 1].specgram( + values, NFFT=8, Fs=8, noverlap=4 + ) + + expected_frequency = np.arange(5.0) + np.testing.assert_array_equal(psd_frequency, expected_frequency) + np.testing.assert_array_equal(csd_frequency, expected_frequency) + np.testing.assert_array_equal(coherence_frequency, expected_frequency) + np.testing.assert_array_equal(specgram_frequency, expected_frequency) + np.testing.assert_allclose( + pxx, + [ + 416.64583333333326, + 295.1202610077128, + 3.262486283380658, + 0.2026258643942779, + 0.0001600718929495325, + ], + rtol=2e-13, + atol=1e-15, + ) + np.testing.assert_allclose( + cross, + [ + 877.9166666666665 + 0j, + 621.7620582723312 + 1.739820039209479j, + 6.85707872472207 + 0.07949547574694463j, + 0.4266093139222703 + 0.00284381010992434j, + 0.0003201437858990622 + 0j, + ], + rtol=2e-13, + atol=1e-14, + ) + np.testing.assert_allclose( + coherence, + [0.9997457628240807, 0.9997470977563064, 0.9997692102108691, 0.9997533865121703, 1.0], + rtol=2e-13, + atol=1e-15, + ) + np.testing.assert_array_equal(time, np.arange(0.5, 4.0, 0.5)) + np.testing.assert_allclose( + spectrum[:, 0], + [ + 106.3125, + 75.9116689989058, + 0.9529375770392008, + 0.05409991287616042, + 0.0001600718929494961, + ], + rtol=2e-13, + atol=1e-15, + ) + np.testing.assert_allclose( + spectrum[:, -1], + [820.3125, 580.256249109394, 6.266636104411269, 0.3958212750155887, 0.00016007189294957457], + rtol=2e-13, + atol=1e-15, + ) + + +@pytest.mark.parametrize("method", ["psd", "csd", "cohere", "specgram"]) +def test_explicit_spectral_detrending_fails_loudly(method: str) -> None: + _fig, ax = plt.subplots() + values = np.arange(32.0) + 10.0 + args = (values, values * 2.0 + 3.0) if method in {"csd", "cohere"} else (values,) + + with pytest.raises(TypeError, match=r"unsupported keyword.*detrend"): + getattr(ax, method)(*args, NFFT=8, detrend="mean") From 8c79190fac2ee20efb6365c082cbc5ec17e4d02d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 22:36:52 -0700 Subject: [PATCH 2/7] Match Matplotlib box and violin defaults --- python/xy/pyplot/_plot_types.py | 439 +++++++----------- spec/matplotlib/compat-changelog.md | 16 + spec/matplotlib/compat.md | 2 +- tests/pyplot/test_axes_charts.py | 4 +- .../pyplot/test_box_violin_default_compat.py | 120 +++++ 5 files changed, 302 insertions(+), 279 deletions(-) create mode 100644 tests/pyplot/test_box_violin_default_compat.py diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..0261f665 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -2187,75 +2187,6 @@ def boxplot( if vert is not None: orientation = "vertical" if vert else "horizontal" values = _from_data(x, data) - advanced = any( - ( - bool(notch), - whis not in (None, 1.5), - bootstrap is not None, - usermedians is not None, - conf_intervals is not None, - bool(showmeans), - showcaps is False, - showbox is False, - sym is not None, - autorange, - capwidths is not None, - bool(patch_artist), - not manage_ticks, - zorder is not None, - tick_labels is not None, - any( - item is not None - for item in ( - boxprops, - flierprops, - medianprops, - meanprops, - capprops, - whiskerprops, - ) - ), - ) - ) - if not advanced: - color = self._next_color() - if positions is None: - if ( - isinstance(values, (list, tuple)) - and values - and all(np.ndim(value) == 1 for value in values) - ): - count = len(values) - else: - array = np.asarray(values) - count = array.shape[1] if array.ndim == 2 else 1 - positions = np.arange(1, count + 1, dtype=np.float64) - entry = self._add( - "@mark", - { - "factory": "box", - "args": (values,), - "kwargs": { - "x": positions, - "name": str(label) if label is not None else None, - "color": color, - "width": _float(widths) - if np.isscalar(widths) and widths is not None - else 0.6, - "orientation": orientation, - "show_outliers": True if showfliers is None else bool(showfliers), - }, - }, - ) - artist = Artist(self, entry) - return { - "whiskers": [artist], - "caps": [artist], - "boxes": [artist], - "medians": [artist], - "fliers": [artist] if showfliers is not False else [], - "means": [], - } if isinstance(values, (list, tuple)) and values and all(np.ndim(v) == 1 for v in values): groups = [np.asarray(v, dtype=np.float64) for v in values] else: @@ -2399,60 +2330,6 @@ def violinplot( if array.ndim == 1 else [np.asarray(array[:, index]) for index in range(array.shape[1])] ) - if bw_method is None and side == "both" and quantiles is None: - entry = self._add( - "@mark", - { - "factory": "violin", - "args": (values,), - "kwargs": { - "x": positions, - "color": resolve_color(facecolor) - if facecolor is not None - else self._next_color(), - "width": float(widths), - "bins": max(4, min(1024, int(points))), - "orientation": orientation, - }, - }, - ) - result: dict[str, Any] = {"bodies": [Artist(self, entry)]} - centers = np.arange(1, len(groups) + 1) if positions is None else np.asarray(positions) - extrema_color = linecolor if linecolor is not None else "#222222" - minima = np.asarray([np.nanmin(group) for group in groups]) - maxima = np.asarray([np.nanmax(group) for group in groups]) - if showextrema: - if orientation == "vertical": - result["cbars"] = self.vlines(centers, minima, maxima, colors=extrema_color) - result["cmins"] = self.hlines( - minima, centers - widths * 0.2, centers + widths * 0.2, colors=extrema_color - ) - result["cmaxes"] = self.hlines( - maxima, centers - widths * 0.2, centers + widths * 0.2, colors=extrema_color - ) - else: - result["cbars"] = self.hlines(centers, minima, maxima, colors=extrema_color) - result["cmins"] = self.vlines( - minima, centers - widths * 0.2, centers + widths * 0.2, colors=extrema_color - ) - result["cmaxes"] = self.vlines( - maxima, centers - widths * 0.2, centers + widths * 0.2, colors=extrema_color - ) - if showmeans: - made = np.asarray([np.nanmean(group) for group in groups]) - result["cmeans"] = ( - self.hlines(made, centers - widths * 0.2, centers + widths * 0.2) - if orientation == "vertical" - else self.vlines(made, centers - widths * 0.2, centers + widths * 0.2) - ) - if showmedians: - made = np.asarray([np.nanmedian(group) for group in groups]) - result["cmedians"] = ( - self.hlines(made, centers - widths * 0.2, centers + widths * 0.2) - if orientation == "vertical" - else self.vlines(made, centers - widths * 0.2, centers + widths * 0.2) - ) - return result if points < 2: raise ValueError("violinplot points must be at least 2") vpstats: list[dict[str, Any]] = [] @@ -3267,7 +3144,11 @@ def bxp( ) if pos.shape != (count,): raise ValueError("bxp positions must match bxpstats") - box_widths = np.asarray(_sequence_param(0.5 if widths is None else widths, count, "widths")) + default_width = float(np.clip(0.15 * np.ptp(pos), 0.15, 0.5)) if count else 0.15 + box_widths = np.asarray( + _sequence_param(default_width if widths is None else widths, count, "widths"), + dtype=np.float64, + ) cap_width_values = np.asarray( _sequence_param( box_widths * 0.5 if capwidths is None else capwidths, count, "capwidths" @@ -3294,13 +3175,13 @@ def style( dash_pattern, ) - default_color = self._next_color() - - def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Artist]: - if not coords: - return [] + def emit( + coords: list[tuple[float, float, float, float]], + props: Any, + fallback: Any, + ) -> Artist: values = np.asarray(coords, dtype=np.float64) - rendered_style, dash_pattern = style(props, default_color) + rendered_style, dash_pattern = style(props, fallback) x0, y0, x1, y1 = ( values[:, 0], values[:, 1], @@ -3317,130 +3198,18 @@ def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Ar "kwargs": rendered_style, }, ) - return [Artist(self, entry)] - - box_segments: list[tuple[float, float, float, float]] = [] - median_segments: list[tuple[float, float, float, float]] = [] - whisker_segments: list[tuple[float, float, float, float]] = [] - cap_segments: list[tuple[float, float, float, float]] = [] - mean_segments: list[tuple[float, float, float, float]] = [] - mean_x: list[float] = [] - mean_y: list[float] = [] - flier_x: list[float] = [] - flier_y: list[float] = [] - for index, item in enumerate(stats): - required = ("med", "q1", "q3", "whislo", "whishi") - if any(name not in item for name in required): - raise ValueError(f"bxpstats[{index}] is missing a required statistic") - center = float(pos[index]) - half = float(box_widths[index]) * 0.5 - cap_half = float(cap_width_values[index]) * 0.5 - q1, q3 = float(item["q1"]), float(item["q3"]) - med = float(item["med"]) - low, high = float(item["whislo"]), float(item["whishi"]) - if orientation == "vertical": - if shownotches: - cilo = float(item.get("cilo", med)) - cihi = float(item.get("cihi", med)) - notch_half = half * 0.5 - box_segments.extend( - [ - (center - half, q1, center + half, q1), - (center + half, q1, center + half, cilo), - (center + half, cilo, center + notch_half, med), - (center + notch_half, med, center + half, cihi), - (center + half, cihi, center + half, q3), - (center + half, q3, center - half, q3), - (center - half, q3, center - half, cihi), - (center - half, cihi, center - notch_half, med), - (center - notch_half, med, center - half, cilo), - (center - half, cilo, center - half, q1), - ] - ) - else: - box_segments.extend( - [ - (center - half, q1, center + half, q1), - (center + half, q1, center + half, q3), - (center + half, q3, center - half, q3), - (center - half, q3, center - half, q1), - ] - ) - median_segments.append((center - half, med, center + half, med)) - whisker_segments.extend([(center, low, center, q1), (center, q3, center, high)]) - cap_segments.extend( - [ - (center - cap_half, low, center + cap_half, low), - (center - cap_half, high, center + cap_half, high), - ] - ) - flier_x.extend([center] * len(item.get("fliers", ()))) - flier_y.extend(float(value) for value in item.get("fliers", ())) - if showmeans and "mean" in item: - mean = float(item["mean"]) - if meanline: - mean_segments.append((center - half, mean, center + half, mean)) - else: - mean_x.append(center) - mean_y.append(mean) - else: - if shownotches: - cilo = float(item.get("cilo", med)) - cihi = float(item.get("cihi", med)) - notch_half = half * 0.5 - box_segments.extend( - [ - (q1, center - half, q1, center + half), - (q1, center + half, cilo, center + half), - (cilo, center + half, med, center + notch_half), - (med, center + notch_half, cihi, center + half), - (cihi, center + half, q3, center + half), - (q3, center + half, q3, center - half), - (q3, center - half, cihi, center - half), - (cihi, center - half, med, center - notch_half), - (med, center - notch_half, cilo, center - half), - (cilo, center - half, q1, center - half), - ] - ) - else: - box_segments.extend( - [ - (q1, center - half, q1, center + half), - (q1, center + half, q3, center + half), - (q3, center + half, q3, center - half), - (q3, center - half, q1, center - half), - ] - ) - median_segments.append((med, center - half, med, center + half)) - whisker_segments.extend([(low, center, q1, center), (q3, center, high, center)]) - cap_segments.extend( - [ - (low, center - cap_half, low, center + cap_half), - (high, center - cap_half, high, center + cap_half), - ] - ) - flier_x.extend(float(value) for value in item.get("fliers", ())) - flier_y.extend([center] * len(item.get("fliers", ()))) - if showmeans and "mean" in item: - mean = float(item["mean"]) - if meanline: - mean_segments.append((mean, center - half, mean, center + half)) - else: - mean_x.append(mean) - mean_y.append(center) + return Artist(self, entry) def emit_points( - x_values: list[float], - y_values: list[float], + x_values: Sequence[float], + y_values: Sequence[float], props: Any, *, default_marker: str, default_face: Any, default_size: float, default_edge: Any = None, - ) -> list[Artist]: - if not x_values: - return [] + ) -> Artist: source = dict(props or {}) color = source.pop("color", default_face) marker = source.pop("marker", default_marker) @@ -3469,44 +3238,158 @@ def emit_points( }, }, ) - return [Artist(self, entry)] - - result = { - "boxes": emit(box_segments, boxprops) if showbox else [], - "medians": emit(median_segments, medianprops), - "whiskers": emit(whisker_segments, whiskerprops), - "caps": emit(cap_segments, capprops) if showcaps else [], - "means": ( - emit(mean_segments, meanprops) - if meanline - else emit_points( - mean_x, - mean_y, - meanprops, - default_marker="^", - default_face="C2", - default_size=6.0, - ) - ), + return Artist(self, entry) + + result: dict[str, list[Artist]] = { + "boxes": [], + "medians": [], + "whiskers": [], + "caps": [], + "means": [], "fliers": [], } - if showfliers and flier_x: - result["fliers"] = emit_points( - flier_x, - flier_y, - flierprops, - default_marker="o", - default_face="transparent", - default_size=5.0, - default_edge=default_color, + for index, item in enumerate(stats): + required = ("med", "q1", "q3", "whislo", "whishi") + if any(name not in item for name in required): + raise ValueError(f"bxpstats[{index}] is missing a required statistic") + center = float(pos[index]) + half = float(box_widths[index]) * 0.5 + cap_half = float(cap_width_values[index]) * 0.5 + q1, q3 = float(item["q1"]), float(item["q3"]) + med = float(item["med"]) + low, high = float(item["whislo"]), float(item["whishi"]) + if orientation == "vertical": + if shownotches: + cilo = float(item.get("cilo", med)) + cihi = float(item.get("cihi", med)) + notch_half = half * 0.5 + box_segments = [ + (center - half, q1, center + half, q1), + (center + half, q1, center + half, cilo), + (center + half, cilo, center + notch_half, med), + (center + notch_half, med, center + half, cihi), + (center + half, cihi, center + half, q3), + (center + half, q3, center - half, q3), + (center - half, q3, center - half, cihi), + (center - half, cihi, center - notch_half, med), + (center - notch_half, med, center - half, cilo), + (center - half, cilo, center - half, q1), + ] + else: + box_segments = [ + (center - half, q1, center + half, q1), + (center + half, q1, center + half, q3), + (center + half, q3, center - half, q3), + (center - half, q3, center - half, q1), + ] + median_segment = (center - half, med, center + half, med) + whisker_segments = [ + (center, low, center, q1), + (center, q3, center, high), + ] + cap_segments = [ + (center - cap_half, low, center + cap_half, low), + (center - cap_half, high, center + cap_half, high), + ] + flier_values = [float(value) for value in item.get("fliers", ())] + flier_x = [center] * len(flier_values) + flier_y = flier_values + if showmeans and "mean" in item: + mean = float(item["mean"]) + if meanline: + mean_segment = (center - half, mean, center + half, mean) + else: + mean_point = (center, mean) + else: + if shownotches: + cilo = float(item.get("cilo", med)) + cihi = float(item.get("cihi", med)) + notch_half = half * 0.5 + box_segments = [ + (q1, center - half, q1, center + half), + (q1, center + half, cilo, center + half), + (cilo, center + half, med, center + notch_half), + (med, center + notch_half, cihi, center + half), + (cihi, center + half, q3, center + half), + (q3, center + half, q3, center - half), + (q3, center - half, cihi, center - half), + (cihi, center - half, med, center - notch_half), + (med, center - notch_half, cilo, center - half), + (cilo, center - half, q1, center - half), + ] + else: + box_segments = [ + (q1, center - half, q1, center + half), + (q1, center + half, q3, center + half), + (q3, center + half, q3, center - half), + (q3, center - half, q1, center - half), + ] + median_segment = (med, center - half, med, center + half) + whisker_segments = [ + (low, center, q1, center), + (q3, center, high, center), + ] + cap_segments = [ + (low, center - cap_half, low, center + cap_half), + (high, center - cap_half, high, center + cap_half), + ] + flier_values = [float(value) for value in item.get("fliers", ())] + flier_x = flier_values + flier_y = [center] * len(flier_values) + if showmeans and "mean" in item: + mean = float(item["mean"]) + if meanline: + mean_segment = (mean, center - half, mean, center + half) + else: + mean_point = (mean, center) + + if showbox: + result["boxes"].append(emit(box_segments, boxprops, "black")) + result["medians"].append(emit([median_segment], medianprops, "C1")) + result["whiskers"].extend( + emit([segment], whiskerprops, "black") for segment in whisker_segments ) + if showcaps: + result["caps"].extend( + emit([segment], capprops, "black") for segment in cap_segments + ) + if showmeans and "mean" in item: + if meanline: + result["means"].append(emit([mean_segment], meanprops, "C2")) + else: + result["means"].append( + emit_points( + [mean_point[0]], + [mean_point[1]], + meanprops, + default_marker="^", + default_face="C2", + default_size=6.0, + ) + ) + if showfliers: + result["fliers"].append( + emit_points( + flier_x, + flier_y, + flierprops, + default_marker="o", + default_face="transparent", + default_size=5.0, + default_edge="black", + ) + ) if label is not None and result["medians"]: result["medians"][0].set_label(str(label)) if zorder is not None: for artists in result.values(): for artist in artists: artist.set_zorder(float(zorder)) - if manage_ticks: + if manage_ticks and result["medians"]: + category_axis = "x" if orientation == "vertical" else "y" + category_extent = np.concatenate((pos - 0.5, pos + 0.5)) + result["medians"][0]._entry["_mpl_extent"] = {category_axis: category_extent} + result["medians"][0]._entry["_mpl_sticky_edges"] = {category_axis: category_extent} (self.set_xticks if orientation == "vertical" else self.set_yticks)(pos) return result @@ -3595,7 +3478,11 @@ def violin( { "factory": "triangle_mesh", "args": (x0, y0, x1, y1, x2, y2), - "kwargs": {"color": body_color, "opacity": 0.8}, + "kwargs": { + "color": body_color, + "opacity": 0.3, + "_joined_fill": True, + }, }, ) bodies.append(Artist(self, entry)) diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 7c0623e5..94cb1230 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,22 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `xy.pyplot.boxplot` no longer routes its default call through the native + opinionated box mark. It now draws Matplotlib's unfilled line geometry and + returns one box, median, and flier handle plus two whisker and cap handles per + group. Fliers stay centered on their group even when several groups are + present, and empty groups of fliers still have the expected handle. +- `xy.pyplot.violinplot` now uses the same Gaussian-KDE path for its default + Scott bandwidth as it does for explicit Scott, Silverman, scalar, and + callable bandwidths. It returns one body per group, with triangle joins + marked as a single fill so browser, PNG, and SVG output suppress internal + seams. +- The public composition API keeps its independent native `box` and `violin` + marks and their opinionated styling; this compatibility correction is + contained inside `xy.pyplot`. + ## Histogram and spectral numeric semantics — 2026-07-26 (Matplotlib 3.11.1 reference) - `hist(density=True, stacked=True)` now bins raw per-dataset mass, stacks it, diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0e8a947a..187cded1 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -55,7 +55,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | | `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 | | `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; 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. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `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`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `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 | +| `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 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). Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free joined body per group, 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 | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `psd`, `csd`, `cohere`, `specgram` | Native Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Explicit detrending modes remain unsupported and fail loudly instead of silently changing the signal | diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..62580403 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -175,8 +175,8 @@ def test_existing_core_plot_families_are_exposed_by_adapter() -> None: assert set(violin) == {"bodies", "cbars", "cmins", "cmaxes"} kinds = [trace.kind for trace in _traces(ax)] assert "stem" in kinds - assert "box" in kinds - assert "violin" in kinds + assert "segments" in kinds + assert "triangle_mesh" in kinds assert "errorbar" in kinds assert "hexbin" in kinds assert kinds.count("contour") == 1 diff --git a/tests/pyplot/test_box_violin_default_compat.py b/tests/pyplot/test_box_violin_default_compat.py new file mode 100644 index 00000000..bc28eba0 --- /dev/null +++ b/tests/pyplot/test_box_violin_default_compat.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from io import BytesIO + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy._figure import Figure + + +def teardown_function() -> None: + plt.close("all") + + +def test_default_boxplot_has_matplotlib_component_cardinalities_and_geometry() -> None: + _fig, ax = plt.subplots() + result = ax.boxplot( + [[0.0, 0.0, 0.0, 0.0, 10.0], [1.0, 1.0, 1.0, 1.0, 20.0]], + showmeans=True, + ) + + assert {name: len(artists) for name, artists in result.items()} == { + "whiskers": 4, + "caps": 4, + "boxes": 2, + "medians": 2, + "fliers": 2, + "means": 2, + } + assert all(artist._entry["factory"] == "segments" for artist in result["boxes"]) + assert all(artist._entry["kwargs"]["color"] == "black" for artist in result["boxes"]) + assert all(artist._entry["kwargs"]["color"] == "#ff7f0e" for artist in result["medians"]) + + # Two positions use Matplotlib's clipped 0.15 default box width. + first_box_x = np.concatenate( + (result["boxes"][0]._entry["args"][0], result["boxes"][0]._entry["args"][2]) + ) + np.testing.assert_allclose([first_box_x.min(), first_box_x.max()], [0.925, 1.075]) + np.testing.assert_allclose(result["fliers"][0]._entry["x"], [1.0]) + np.testing.assert_allclose(result["fliers"][1]._entry["x"], [2.0]) + + +def test_default_boxplot_returns_one_empty_flier_handle_per_group() -> None: + _fig, ax = plt.subplots() + result = ax.boxplot([[1.0, 2.0, 3.0], [2.0, 4.0, 8.0]]) + + assert len(result["fliers"]) == 2 + assert all(len(artist._entry["x"]) == 0 for artist in result["fliers"]) + + +@pytest.mark.parametrize( + ("bw_method", "factor"), + [ + (None, 5 ** (-1 / 5)), + ("scott", 5 ** (-1 / 5)), + ("silverman", (5 * 3 / 4) ** (-1 / 5)), + (0.5, 0.5), + ], +) +def test_violinplot_uses_gaussian_kde_for_supported_bandwidths( + bw_method: str | float | None, factor: float +) -> None: + values = np.asarray([0.0, 0.25, 1.0, 2.0, 4.0]) + _fig, ax = plt.subplots() + result = ax.violinplot([values], points=31, bw_method=bw_method, showextrema=False) + + assert len(result["bodies"]) == 1 + body = result["bodies"][0]._entry + assert body["factory"] == "triangle_mesh" + assert body["kwargs"]["_joined_fill"] is True + assert body["kwargs"]["opacity"] == pytest.approx(0.3) + + coords = np.linspace(values.min(), values.max(), 31) + bandwidth = np.std(values, ddof=1) * factor + delta = (coords[:, None] - values[None, :]) / bandwidth + density = np.mean(np.exp(-0.5 * delta * delta), axis=1) / (bandwidth * np.sqrt(2 * np.pi)) + target = 11 + target_x = np.concatenate( + [ + np.asarray(x)[np.isclose(y, coords[target])] + for x, y in zip(body["args"][::2], body["args"][1::2], strict=True) + ] + ) + expected_half_width = density[target] / density.max() * 0.25 + np.testing.assert_allclose( + [target_x.min(), target_x.max()], + [1.0 - expected_half_width, 1.0 + expected_half_width], + ) + + +def test_violinplot_returns_one_joined_body_per_group_and_renders() -> None: + fig, ax = plt.subplots(figsize=(4, 3)) + result = ax.violinplot( + [[0.0, 0.5, 1.0, 2.0], [1.0, 2.0, 4.0, 8.0]], + showmedians=True, + ) + + assert len(result["bodies"]) == 2 + traces = ax._build_chart(400, 300).figure().traces + body_traces = [trace for trace in traces if trace.kind == "triangle_mesh"] + assert len(body_traces) == 2 + assert all(trace.style["joined_fill"] is True for trace in body_traces) + + pixels = np.asarray(plt.imread(BytesIO(fig._to_png()))) + assert pixels.shape[:2] == (300, 400) + assert np.count_nonzero(np.any(pixels[..., :3] < 242, axis=-1)) > 500 + + +def test_native_core_box_and_violin_marks_keep_their_fast_paths() -> None: + figure = Figure() + figure.box([[1.0, 2.0, 3.0], [2.0, 4.0, 8.0]]) + figure.violin([[1.0, 2.0, 3.0], [2.0, 4.0, 8.0]]) + + assert [trace.kind for trace in figure.traces] == [ + "box_whisker", + "box", + "box_median", + "violin", + ] From 2c9658a3b2d9932d87577a55d3b99059b09fa383 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 23:46:20 -0700 Subject: [PATCH 3/7] Fix dense histogram inset rendering --- python/xy/pyplot/_axes.py | 18 +++++++--- .../test_gallery_hist_errorbar_compat.py | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 09e848d9..4e33c296 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2164,7 +2164,9 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ if linestyle_value not in LINESTYLE_TO_DASH: raise ValueError(f"unsupported histogram linestyle: {linestyle_value!r}") dash = self._mpl_dash(LINESTYLE_TO_DASH[linestyle_value], resolved_width) - filled = histtype == "stepfilled" if fill is None else bool(fill) + # Matplotlib's Patch._set_edgecolor(None) makes a filled patch's + # edge transparent unless patch.force_edgecolor is enabled. + filled = histtype in {"bar", "barstacked", "stepfilled"} if fill is None else bool(fill) if not filled and resolved_edge is None: resolved_edge = ( series_color @@ -2190,8 +2192,11 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ "color": resolved_color, "opacity": 1.0 if alpha is None else float(alpha), "name": None if labels[index] is None else str(labels[index]), - "stroke": resolved_edge, - "stroke_width": resolved_width, + **( + {"stroke": resolved_edge, "stroke_width": resolved_width} + if resolved_edge is not None + else {} + ), }, }, ) @@ -2269,8 +2274,11 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ "color": "transparent" if fill is False else resolved_color, "opacity": 1.0 if alpha is None else float(alpha), "name": None if labels[index] is None else str(labels[index]), - "stroke": resolved_edge if dash is None else None, - "stroke_width": resolved_width, + **( + {"stroke": resolved_edge, "stroke_width": resolved_width} + if resolved_edge is not None and dash is None + else {} + ), }, }, ) diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index 08d73d62..e9df541b 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +from io import BytesIO + import numpy as np import pytest @@ -42,6 +45,39 @@ def test_hist_fill_false_is_unfilled_and_scalar_input_is_one_dataset() -> None: assert entry["kwargs"]["stroke_width"] == 3.0 +def test_hist_default_patch_edge_is_transparent_like_matplotlib() -> None: + _fig, ax = plt.subplots() + + ax.hist([0.0, 1.0, 2.0], bins=2) + + kwargs = ax._entries[-1]["kwargs"] + assert "stroke" not in kwargs + assert "stroke_width" not in kwargs + + +def test_dense_histogram_remains_visible_on_dark_absolute_inset() -> None: + rng = np.random.default_rng(19680801) + fig, main_ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + main_ax.plot([0.0, 1.0], [0.0, 1.0]) + inset = fig.add_axes((0.65, 0.6, 0.2, 0.2), facecolor="black") + inset.hist(rng.normal(size=10_000), 400, density=True) + inset.set(xticks=[], yticks=[]) + + pixels = np.asarray(plt.imread(BytesIO(fig._to_png()))) + crop = pixels[96:192, 416:544, :3] + blue = (crop[:, :, 2] > crop[:, :, 0] + 0.2) & (crop[:, :, 2] > crop[:, :, 1] + 0.1) + assert np.count_nonzero(blue) > 100 + + output = BytesIO() + fig.savefig(output, format="svg") + histogram_rects = re.findall( + r']+fill="rgb\(31,119,180\)"[^>]*/>', + output.getvalue().decode(), + ) + assert len(histogram_rects) == 400 + assert all("stroke=" not in rect for rect in histogram_rects) + + def test_hist_step_fill_and_linewidth_override_histtype_defaults() -> None: _fig, ax = plt.subplots() ax.hist([0, 1, 2], bins=2, histtype="step", fill=True, facecolor="green", linewidth=4) From dee0b6a0145315017f88621b412ee19bb915ad84 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 02:01:08 -0700 Subject: [PATCH 4/7] Fix dashed horizontal step histograms --- python/xy/pyplot/_axes.py | 78 ++++++++++++------- .../test_gallery_statistics_semantics.py | 22 ++++++ 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 4e33c296..05bdd71e 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2143,6 +2143,46 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ if len(width) and np.allclose(width, width[0], rtol=1e-12, atol=0.0) else width ) + + def add_dashed_bar_perimeters( + positions: np.ndarray, + values: np.ndarray, + current_base: np.ndarray, + resolved_edge: Optional[str], + resolved_width: float, + dash: Any, + ) -> None: + if dash in (None, "none") or resolved_edge is None: + return + half_width = width / 2.0 + low = positions - half_width + high = positions + half_width + top = current_base + values + if orientation == "vertical": + x0 = np.concatenate((low, high, high, low)) + y0 = np.concatenate((current_base, current_base, top, top)) + x1 = np.concatenate((high, high, low, low)) + y1 = np.concatenate((current_base, top, top, current_base)) + else: + x0 = np.concatenate((current_base, current_base, top, top)) + y0 = np.concatenate((low, high, high, low)) + x1 = np.concatenate((current_base, top, top, current_base)) + y1 = np.concatenate((high, high, low, low)) + self._add( + "@mark", + { + "factory": "segments", + "args": (x0, y0, x1, y1), + "kwargs": { + "color": resolved_edge, + "width": resolved_width, + "dash": dash, + "opacity": 1.0 if alpha is None else float(alpha), + "name": None, + }, + }, + ) + for index, values in enumerate(counts): positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width current_base = base.copy() if stacked else np.zeros_like(values) @@ -2175,6 +2215,7 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ ) elif filled and histtype == "step" and resolved_edge is None: resolved_edge = series_color + if orientation == "horizontal" and histtype.startswith("step") and filled: # The core area primitive fills along y. Horizontal filled # steps are equivalently represented by touching horizontal @@ -2194,12 +2235,15 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ "name": None if labels[index] is None else str(labels[index]), **( {"stroke": resolved_edge, "stroke_width": resolved_width} - if resolved_edge is not None + if resolved_edge is not None and dash is None else {} ), }, }, ) + add_dashed_bar_perimeters( + positions, values, current_base, resolved_edge, resolved_width, dash + ) elif orientation == "horizontal" and histtype.startswith("step"): step_values = values + current_base path_x = np.repeat(step_values, 2) @@ -2282,35 +2326,9 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ }, }, ) - if dash not in (None, "none") and resolved_edge is not None: - half_width = width / 2.0 - low = positions - half_width - high = positions + half_width - top = current_base + values - if orientation == "vertical": - x0 = np.concatenate((low, high, high, low)) - y0 = np.concatenate((current_base, current_base, top, top)) - x1 = np.concatenate((high, high, low, low)) - y1 = np.concatenate((current_base, top, top, current_base)) - else: - x0 = np.concatenate((current_base, current_base, top, top)) - y0 = np.concatenate((low, high, high, low)) - x1 = np.concatenate((current_base, top, top, current_base)) - y1 = np.concatenate((high, high, low, low)) - self._add( - "@mark", - { - "factory": "segments", - "args": (x0, y0, x1, y1), - "kwargs": { - "color": resolved_edge, - "width": resolved_width, - "dash": dash, - "opacity": 1.0 if alpha is None else float(alpha), - "name": None, - }, - }, - ) + add_dashed_bar_perimeters( + positions, values, current_base, resolved_edge, resolved_width, dash + ) if hatches[index]: self._stairs_hatch( values + current_base, diff --git a/tests/pyplot/test_gallery_statistics_semantics.py b/tests/pyplot/test_gallery_statistics_semantics.py index 997a7126..bafbb02c 100644 --- a/tests/pyplot/test_gallery_statistics_semantics.py +++ b/tests/pyplot/test_gallery_statistics_semantics.py @@ -72,6 +72,28 @@ def test_hist_per_dataset_linestyles_emit_distinct_outlines() -> None: assert outlined[0]["kwargs"]["color"] == "red" +def test_horizontal_stepfilled_hist_dashes_use_segment_perimeters() -> None: + _fig, ax = plt.subplots() + + ax.hist( + [0.1, 0.2, 0.8, 1.3, 1.7], + bins=[0.0, 0.5, 1.0, 2.0], + histtype="stepfilled", + orientation="horizontal", + edgecolor="red", + linestyle="--", + ) + + bars, outline = ax._entries + assert bars["kind"] == "bar" + assert "stroke" not in bars["kwargs"] + assert "stroke_width" not in bars["kwargs"] + assert outline["factory"] == "segments" + assert outline["kwargs"]["color"] == "red" + assert outline["kwargs"]["dash"] + assert len(outline["args"][0]) == 12 # four perimeter segments per bin + + def test_hist_negative_cumulative_runs_from_right_to_left() -> None: _fig, ax = plt.subplots() From 2b18c09572449aef79bd8e6d9edcc8914d612dbd Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:57:01 -0700 Subject: [PATCH 5/7] Close histogram step outlines to baseline --- python/xy/pyplot/_axes.py | 41 ++++++++++++- spec/matplotlib/compat.md | 2 +- .../test_gallery_hist_errorbar_compat.py | 58 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 05bdd71e..b5f825d3 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2246,8 +2246,24 @@ def add_dashed_bar_perimeters( ) elif orientation == "horizontal" and histtype.startswith("step"): step_values = values + current_base - path_x = np.repeat(step_values, 2) - path_y = np.repeat(edges, 2)[1:-1] + # Matplotlib's unfilled step histogram is the top envelope + # plus one connector to the current baseline at each end. A + # stacked series therefore starts/ends at the previous stack, + # not at zero. + path_x = np.concatenate( + ( + current_base[:1], + np.repeat(step_values, 2), + current_base[-1:], + ) + ) + path_y = np.concatenate( + ( + edges[:1], + np.repeat(edges, 2)[1:-1], + edges[-1:], + ) + ) entry = self._add( "@mark", { @@ -2291,6 +2307,27 @@ def add_dashed_bar_perimeters( ) elif histtype.startswith("step"): step_values = values + current_base + # Keep the compact stairs trace for the O(bins) top envelope, + # then add only Matplotlib's two missing baseline connectors. + self._add( + "@mark", + { + "factory": "segments", + "args": ( + edges[[0, -1]], + np.asarray((current_base[0], step_values[-1])), + edges[[0, -1]], + np.asarray((step_values[0], current_base[-1])), + ), + "kwargs": { + "color": resolved_edge or series_color, + "width": resolved_width, + "dash": dash, + "name": None, + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) entry = self._add( "@mark", { diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 187cded1..8e563c27 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -53,7 +53,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `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 | +| `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; 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. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `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 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). Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free joined body per group, 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 | diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index e9df541b..c36b8984 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -83,15 +83,71 @@ def test_hist_step_fill_and_linewidth_override_histtype_defaults() -> None: ax.hist([0, 1, 2], bins=2, histtype="step", fill=True, facecolor="green", linewidth=4) ax.hist([0, 1, 2], bins=2, histtype="stepfilled", fill=False, linewidth=5) - filled, unfilled = ax._entries + filled, connectors, unfilled = ax._entries assert filled["factory"] == "area" assert filled["kwargs"]["color"] == "green" assert filled["kwargs"]["line_color"] == "#1f77b4" + assert connectors["factory"] == "segments" + assert connectors["kwargs"].get("name") is None assert unfilled["factory"] == "stairs" assert unfilled["kwargs"]["color"] == "black" assert unfilled["kwargs"]["width"] == 5.0 +def test_hist_step_outline_connects_to_baseline_in_both_orientations() -> None: + _fig, (vertical_ax, horizontal_ax) = plt.subplots(1, 2) + data = [0.2, 0.4, 1.2, 1.4, 1.6] + bins = [0.0, 1.0, 2.0] + + vertical_counts, vertical_edges, _ = vertical_ax.hist(data, bins=bins, histtype="step") + horizontal_counts, horizontal_edges, _ = horizontal_ax.hist( + data, + bins=bins, + histtype="step", + orientation="horizontal", + ) + + connectors, stairs = vertical_ax._entries + assert connectors["factory"] == "segments" + assert stairs["factory"] == "stairs" + x0, y0, x1, y1 = connectors["args"] + np.testing.assert_allclose(x0, [vertical_edges[0], vertical_edges[-1]]) + np.testing.assert_allclose(x1, [vertical_edges[0], vertical_edges[-1]]) + np.testing.assert_allclose(y0, [0.0, vertical_counts[-1]]) + np.testing.assert_allclose(y1, [vertical_counts[0], 0.0]) + + (outline,) = horizontal_ax._entries + assert outline["factory"] == "segments" + x0, y0, x1, y1 = outline["args"] + assert (x0[0], y0[0]) == pytest.approx((0.0, horizontal_edges[0])) + assert (x1[0], y1[0]) == pytest.approx((horizontal_counts[0], horizontal_edges[0])) + assert (x0[-1], y0[-1]) == pytest.approx((horizontal_counts[-1], horizontal_edges[-1])) + assert (x1[-1], y1[-1]) == pytest.approx((0.0, horizontal_edges[-1])) + + +def test_stacked_hist_step_outline_connects_to_previous_stack() -> None: + _fig, ax = plt.subplots() + datasets = [[0.2, 0.4, 1.2], [0.3, 1.3, 1.5]] + + counts, edges, _ = ax.hist( + datasets, + bins=[0.0, 1.0, 2.0], + histtype="step", + stacked=True, + ) + + first_connectors, _first_stairs, second_connectors, _second_stairs = ax._entries + _x0, first_y0, _x1, first_y1 = first_connectors["args"] + np.testing.assert_allclose(first_y0, [0.0, counts[0, -1]]) + np.testing.assert_allclose(first_y1, [counts[0, 0], 0.0]) + + x0, second_y0, x1, second_y1 = second_connectors["args"] + np.testing.assert_allclose(x0, [edges[0], edges[-1]]) + np.testing.assert_allclose(x1, [edges[0], edges[-1]]) + np.testing.assert_allclose(second_y0, [counts[0, 0], counts[1, -1]]) + np.testing.assert_allclose(second_y1, [counts[1, 0], counts[0, -1]]) + + def test_hist_dataset_style_lengths_must_match_dataset_count() -> None: _fig, ax = plt.subplots() with pytest.raises(ValueError, match="sequence must have length 2"): From 1c3528bc2e0fa5f3a626ab71fa8b6863f2eb9350 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 09:35:53 -0700 Subject: [PATCH 6/7] Make stacked-step geometry test order independent --- .../test_gallery_hist_errorbar_compat.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index c36b8984..de0fefaf 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -136,16 +136,22 @@ def test_stacked_hist_step_outline_connects_to_previous_stack() -> None: stacked=True, ) - first_connectors, _first_stairs, second_connectors, _second_stairs = ax._entries - _x0, first_y0, _x1, first_y1 = first_connectors["args"] - np.testing.assert_allclose(first_y0, [0.0, counts[0, -1]]) - np.testing.assert_allclose(first_y1, [counts[0, 0], 0.0]) - - x0, second_y0, x1, second_y1 = second_connectors["args"] - np.testing.assert_allclose(x0, [edges[0], edges[-1]]) - np.testing.assert_allclose(x1, [edges[0], edges[-1]]) - np.testing.assert_allclose(second_y0, [counts[0, 0], counts[1, -1]]) - np.testing.assert_allclose(second_y1, [counts[1, 0], counts[0, -1]]) + # Matplotlib inserts stacked step artists from the top dataset downward, + # then restores only the returned container list to dataset order. Keep + # this geometry assertion independent of that renderer insertion order. + connectors = [entry for entry in ax._entries if entry.get("factory") == "segments"] + assert len(connectors) == 2 + actual = [] + for connector in connectors: + x0, y0, x1, y1 = connector["args"] + np.testing.assert_allclose(x0, [edges[0], edges[-1]]) + np.testing.assert_allclose(x1, [edges[0], edges[-1]]) + actual.append((tuple(y0), tuple(y1))) + expected = [ + ((0.0, counts[0, -1]), (counts[0, 0], 0.0)), + ((counts[0, 0], counts[1, -1]), (counts[1, 0], counts[0, -1])), + ] + assert sorted(actual) == sorted(expected) def test_hist_dataset_style_lengths_must_match_dataset_count() -> None: From 25e8b3946c9e495b1649f90c3b08d70706afe398 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:13:38 -0700 Subject: [PATCH 7/7] Fix statistics gallery patch and norm compatibility --- python/xy/pyplot/_artists.py | 54 ++++ python/xy/pyplot/_plot_types.py | 246 ++++++++++++++---- spec/matplotlib/compat.md | 6 +- spec/matplotlib/shim-todo.md | 18 +- .../pyplot/test_box_violin_default_compat.py | 103 ++++++++ .../test_statistics_numeric_regressions.py | 47 ++++ 6 files changed, 413 insertions(+), 61 deletions(-) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index eb627f66..ec85d01d 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1200,6 +1200,60 @@ class PolyCollection(Artist): def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: _set_entry_clim(self, vmin, vmax) + def get_facecolors(self) -> np.ndarray: + """Return the collection's constant face paint as one RGBA row.""" + return resolve_rgba_array( + self._entry.get("kwargs", {}).get("color", "transparent"), + 1, + "collection facecolors", + ) + + get_facecolor = get_facecolors + + 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) + self._touch() + + set_facecolor = set_facecolors + + def get_edgecolors(self) -> np.ndarray: + """Return the collection's constant boundary paint as one RGBA row.""" + return resolve_rgba_array( + self._entry.get("kwargs", {}).get("stroke", "transparent"), + 1, + "collection edgecolors", + ) + + get_edgecolor = get_edgecolors + + def set_edgecolors(self, colors: Any) -> None: + """Set a constant boundary paint for a filled polygon collection.""" + rgba = resolve_rgba_array(colors, 1, "collection edgecolors")[0] + self._entry.setdefault("kwargs", {})["stroke"] = _rgba_css(rgba) + self._touch() + + set_edgecolor = set_edgecolors + + def set_linewidths(self, widths: Any) -> None: + """Set the polygon boundary width in Matplotlib linewidth units.""" + values = np.asarray(widths, dtype=np.float64).reshape(-1) + if values.size != 1 or not np.isfinite(values[0]) or values[0] < 0.0: + raise ValueError("collection linewidth must be one non-negative finite value") + self._entry.setdefault("kwargs", {})["stroke_width"] = float(values[0]) + self._touch() + + set_linewidth = set_linewidths + + def get_linewidths(self) -> np.ndarray: + return np.asarray( + [self._entry.get("kwargs", {}).get("stroke_width", 0.0)], + dtype=np.float64, + ) + + get_linewidth = get_linewidths + class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 0261f665..a890d9a7 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -85,6 +85,29 @@ def _sequence_param(value: Any, n: int, name: str) -> list[Any]: return result +def _cycled_colors(value: Any, n: int, name: str) -> list[str]: + """Resolve a scalar color or cycle a non-empty color sequence to length ``n``.""" + if n == 0: + return [] + try: + scalar = resolve_color(value) + except (TypeError, ValueError): + sequence = list(value) + else: + if scalar is None: + raise ValueError(f"{name} must not be None") + return [scalar] * n + if not sequence: + raise ValueError(f"{name} must not be empty") + resolved: list[str] = [] + for color in sequence: + item = resolve_color(color) + if item is None: + raise ValueError(f"{name} entries must not be None") + resolved.append(item) + return [resolved[index % len(resolved)] for index in range(n)] + + def _float(value: Any) -> float: return float(value) @@ -2174,7 +2197,7 @@ def boxplot( autorange: bool = False, zorder: float | None = None, capwidths: float | ArrayLike | None = None, - label: str | None = None, + label: str | Sequence[str] | None = None, data: TableLike = None, ) -> dict[str, list[Artist]]: """Box-and-whisker plots of one dataset or a sequence of datasets. @@ -2304,8 +2327,8 @@ def violinplot( points: int = 100, bw_method: Any = None, side: str = "both", - facecolor: ColorLike | None = None, - linecolor: ColorLike | None = None, + facecolor: ColorsLike | None = None, + linecolor: ColorsLike | None = None, data: TableLike = None, ) -> dict[str, Any]: """Violin plots (kernel density estimates) of one or more datasets. @@ -3126,11 +3149,9 @@ def bxp( manage_ticks: bool = True, zorder: float | None = None, capwidths: float | ArrayLike | None = None, - label: str | None = None, + label: str | Sequence[str] | None = None, ) -> dict[str, list[Artist]]: """Draw exact precomputed box geometry with generic segment/scatter marks.""" - if patch_artist: - raise not_implemented("bxp(patch_artist=True)") stats = list(bxpstats) count = len(stats) if vert is not None: @@ -3155,6 +3176,7 @@ def bxp( ), dtype=np.float64, ) + from xy import kernels def style( props: Any, fallback: Any = None @@ -3200,6 +3222,52 @@ def emit( ) return Artist(self, entry) + def emit_patch( + coords: list[tuple[float, float, float, float]], + props: Any, + ) -> PolyCollection: + source = dict(props or {}) + combined = source.pop("color", None) + facecolor = source.pop("facecolor", source.pop("fc", combined or "C0")) + edgecolor = source.pop( + "edgecolor", + source.pop("ec", combined or rcParams["patch.edgecolor"]), + ) + linewidth = source.pop( + "linewidth", + source.pop("lw", rcParams["patch.linewidth"]), + ) + alpha = source.pop("alpha", 1.0) + linestyle = source.pop("linestyle", source.pop("ls", None)) + if linestyle not in (None, "solid", "-"): + raise not_implemented( + "bxp patch box linestyle", + "solid patch boundaries", + ) + check_unsupported(source, "bxp patch properties") + vertices = np.asarray([(x0, y0) for x0, y0, _x1, _y1 in coords]) + topology = kernels.polygon_triangles(vertices[:, 0], vertices[:, 1]) + x0, y0, x1, y1, x2, y2, _ = kernels.indexed_triangles( + vertices[:, 0], + vertices[:, 1], + topology, + ) + entry = self._add( + "@mark", + { + "factory": "triangle_mesh", + "args": (x0, y0, x1, y1, x2, y2), + "kwargs": { + "color": resolve_color(facecolor), + "stroke": resolve_color(edgecolor), + "stroke_width": float(linewidth), + "opacity": float(alpha), + "_joined_fill": True, + }, + }, + ) + return PolyCollection(self, entry) + def emit_points( x_values: Sequence[float], y_values: Sequence[float], @@ -3344,7 +3412,11 @@ def emit_points( mean_point = (mean, center) if showbox: - result["boxes"].append(emit(box_segments, boxprops, "black")) + result["boxes"].append( + emit_patch(box_segments, boxprops) + if patch_artist + else emit(box_segments, boxprops, "black") + ) result["medians"].append(emit([median_segment], medianprops, "C1")) result["whiskers"].extend( emit([segment], whiskerprops, "black") for segment in whisker_segments @@ -3379,8 +3451,16 @@ def emit_points( default_edge="black", ) ) - if label is not None and result["medians"]: - result["medians"][0].set_label(str(label)) + legend_handles = result["boxes"] if patch_artist and showbox else result["medians"] + if label is not None and legend_handles: + if isinstance(label, str): + legend_handles[0].set_label(label) + else: + labels = list(label) + if len(labels) != len(legend_handles): + raise ValueError("bxp label sequence must match the number of boxes") + for handle, item_label in zip(legend_handles, labels, strict=True): + handle.set_label(str(item_label)) if zorder is not None: for artists in result.values(): for artist in artists: @@ -3390,7 +3470,15 @@ def emit_points( category_extent = np.concatenate((pos - 0.5, pos + 0.5)) result["medians"][0]._entry["_mpl_extent"] = {category_axis: category_extent} result["medians"][0]._entry["_mpl_sticky_edges"] = {category_axis: category_extent} - (self.set_xticks if orientation == "vertical" else self.set_yticks)(pos) + set_ticks = self.set_xticks if orientation == "vertical" else self.set_yticks + if any("label" in item for item in stats): + datalabels = [ + str(item.get("label", position)) + for item, position in zip(stats, pos, strict=True) + ] + set_ticks(pos, datalabels) + else: + set_ticks(pos) return result def violin( @@ -3405,8 +3493,8 @@ def violin( showextrema: bool = True, showmedians: bool = False, side: str = "both", - facecolor: ColorLike | None = None, - linecolor: ColorLike | None = None, + facecolor: ColorsLike | None = None, + linecolor: ColorsLike | None = None, ) -> dict[str, Any]: """Draw violin bodies from precomputed coordinates and densities.""" stats = list(vpstats) @@ -3424,11 +3512,23 @@ def violin( width_values = np.asarray(_sequence_param(widths, len(stats), "widths"), dtype=float) if pos.shape != (len(stats),): raise ValueError("violin positions must match vpstats") - body_color = resolve_color(facecolor) if facecolor is not None else self._next_color() - edge_color = resolve_color(linecolor) if linecolor is not None else body_color + default_color = ( + self._next_color() if stats and (facecolor is None or linecolor is None) else "C0" + ) + body_colors = ( + [default_color] * len(stats) + if facecolor is None + else _cycled_colors(facecolor, len(stats), "violin facecolor") + ) + line_colors = ( + [default_color] * len(stats) + if linecolor is None + else _cycled_colors(linecolor, len(stats), "violin linecolor") + ) + body_opacity = 0.3 if facecolor is None else 1.0 from xy import kernels - bodies: list[Artist] = [] + bodies: list[PolyCollection] = [] center_segments: dict[str, list[tuple[float, float, float, float]]] = { "cmeans": [], "cmedians": [], @@ -3437,6 +3537,7 @@ def violin( "cbars": [], "cquantiles": [], } + center_colors: dict[str, list[str]] = {name: [] for name in center_segments} for index, item in enumerate(stats): coords = np.asarray(item["coords"], dtype=np.float64) vals = np.asarray(item["vals"], dtype=np.float64) @@ -3463,7 +3564,7 @@ def violin( "y": [np.nan, np.nan], "kwargs": { "base": [np.nan, np.nan], - "color": body_color, + "color": body_colors[index], "opacity": 0.0, }, }, @@ -3479,13 +3580,13 @@ def violin( "factory": "triangle_mesh", "args": (x0, y0, x1, y1, x2, y2), "kwargs": { - "color": body_color, - "opacity": 0.3, + "color": body_colors[index], + "opacity": body_opacity, "_joined_fill": True, }, }, ) - bodies.append(Artist(self, entry)) + bodies.append(PolyCollection(self, entry)) half = width_values[index] * 0.25 def line_at( @@ -3503,30 +3604,39 @@ def line_at( ) if showextrema: center_segments["cmins"].append(line_at(minimum)) + center_colors["cmins"].append(line_colors[index]) center_segments["cmaxes"].append(line_at(maximum)) + center_colors["cmaxes"].append(line_colors[index]) center_segments["cbars"].append( (center, minimum, center, maximum) if orientation == "vertical" else (minimum, center, maximum, center) ) + center_colors["cbars"].append(line_colors[index]) if showmeans and "mean" in item: center_segments["cmeans"].append(line_at(float(item["mean"]))) + center_colors["cmeans"].append(line_colors[index]) if showmedians and "median" in item: center_segments["cmedians"].append(line_at(float(item["median"]))) - center_segments["cquantiles"].extend( - line_at(float(value)) for value in item.get("quantiles", ()) - ) + center_colors["cmedians"].append(line_colors[index]) + quantiles = list(item.get("quantiles", ())) + center_segments["cquantiles"].extend(line_at(float(value)) for value in quantiles) + center_colors["cquantiles"].extend([line_colors[index]] * len(quantiles)) result: dict[str, Any] = {"bodies": bodies} for name, coordinates in center_segments.items(): if not coordinates: continue values = np.asarray(coordinates, dtype=np.float64) + colors = center_colors[name] + rendered_color: Any = ( + colors[0] if all(color == colors[0] for color in colors) else colors + ) entry = self._add( "@mark", { "factory": "segments", "args": (values[:, 0], values[:, 1], values[:, 2], values[:, 3]), - "kwargs": {"color": edge_color, "width": 1.0}, + "kwargs": {"color": rendered_color, "width": 1.0}, }, ) result[name] = Artist(self, entry) @@ -3550,8 +3660,9 @@ def hist2d( ``bins``/``range``/``density``/``weights`` follow ``numpy.histogram2d``; ``cmin``/``cmax`` blank cells outside the - count window. Supported keywords: ``cmap``, ``alpha``, and - ``vmin``/``vmax``; ``norm`` and unknown keywords raise loudly. + count window. Supported keywords: ``cmap``, ``alpha``, + ``vmin``/``vmax``, and linear or logarithmic normalization. + Unknown keywords raise loudly. Returns ``(counts, xedges, yedges, image)`` as matplotlib does. """ x = np.asarray(_from_data(x, data), dtype=np.float64) @@ -3626,35 +3737,66 @@ def make_edges(spec: Any, bounds: tuple[float, float], label: str) -> np.ndarray vmin = kwargs.pop("vmin", None) vmax = kwargs.pop("vmax", None) norm = kwargs.pop("norm", None) - if norm is not None: - raise not_implemented("hist2d(norm=...)") check_unsupported(kwargs, "hist2d()") - x_uniform = np.allclose(np.diff(xedges), np.diff(xedges)[0]) - y_uniform = np.allclose(np.diff(yedges), np.diff(yedges)[0]) - if not (x_uniform and y_uniform): - image = self.pcolormesh( - xedges, - yedges, - h.T, - cmap=cmap, - alpha=alpha, - vmin=vmin, - vmax=vmax, - ) - return h, xedges, yedges, image - mark_kwargs: dict[str, Any] = { - "x": (xedges[:-1] + xedges[1:]) * 0.5, - "y": (yedges[:-1] + yedges[1:]) * 0.5, - "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", - "opacity": 0.95 if alpha is None else float(alpha), - } - if vmin is not None and vmax is not None: - mark_kwargs["domain"] = (float(vmin), float(vmax)) - entry = self._add( - "@mark", - {"factory": "heatmap", "args": (h.T,), "kwargs": mark_kwargs, "source_z": h.T}, + mesh_values = h.T + mesh_norm = norm + mesh_vmin, mesh_vmax = vmin, vmax + norm_name = norm.lower() if isinstance(norm, str) else type(norm).__name__ + log_domain: tuple[float, float] | None = None + if norm_name == "linear": + mesh_norm = None + elif norm_name in {"log", "LogNorm"}: + if not isinstance(norm, str) and (vmin is not None or vmax is not None): + raise ValueError( + "Passing a Normalize instance simultaneously with vmin/vmax " + "is not supported; set the bounds on the norm instance instead" + ) + raw = np.asarray(mesh_values, dtype=np.float64) + finite_positive = raw[np.isfinite(raw) & (raw > 0.0)] + lo_arg = getattr(norm, "vmin", None) if vmin is None else vmin + hi_arg = getattr(norm, "vmax", None) if vmax is None else vmax + if not finite_positive.size and (lo_arg is None or hi_arg is None): + raise ValueError("log normalization requires at least one positive finite value") + lo = float(lo_arg) if lo_arg is not None else float(finite_positive.min()) + hi = float(hi_arg) if hi_arg is not None else float(finite_positive.max()) + if not np.isfinite([lo, hi]).all() or lo <= 0.0 or hi <= 0.0: + raise ValueError("Invalid vmin or vmax") + if hi < lo: + raise ValueError("vmin must be less than or equal to vmax") + invalid = ~np.isfinite(raw) | (raw <= 0.0) + if callable(norm): + normalized = np.ma.asarray( + norm(np.ma.masked_where(invalid, raw)), + dtype=np.float64, + ).filled(np.nan) + elif hi == lo: + normalized = np.zeros(raw.shape, dtype=np.float64) + else: + normalized = (np.log(raw, where=~invalid, out=np.zeros_like(raw)) - np.log(lo)) / ( + np.log(hi) - np.log(lo) + ) + if normalized.shape != raw.shape: + raise ValueError("normalization must preserve the histogram grid shape") + normalized[invalid] = np.nan + mesh_values = normalized + mesh_norm = None + mesh_vmin, mesh_vmax = 0.0, 1.0 + log_domain = (lo, hi) + image = self.pcolormesh( + xedges, + yedges, + mesh_values, + cmap=cmap, + alpha=alpha, + vmin=mesh_vmin, + vmax=mesh_vmax, + norm=mesh_norm, ) - return h, xedges, yedges, PolyCollection(self, entry) + if log_domain is not None: + image._entry["source_z"] = h.T + image._entry["_mpl_domain"] = log_domain + image._entry["_mpl_norm_scale"] = "log" + return h, xedges, yedges, image def eventplot( self, diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 8e563c27..82745b09 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -54,11 +54,11 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | | `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; 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. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `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 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). Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free joined body per group, 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 | +| `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 | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `psd`, `csd`, `cohere`, `specgram` | Native Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Explicit detrending modes remain unsupported and fail loudly instead of silently changing the signal | +| `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` / `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. 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 | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 0b3d299d..32608962 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -319,15 +319,19 @@ method accepts the call. properties and complete horizontal/negative-bar placement. - [x] `hist`: every histtype, heterogeneous bins, rwidth, log mode, bottom arrays and exact returned patches. -- [x] `hist2d(norm=...)` and complete normalization/colorizer support. +- [x] `hist2d` linear and logarithmic normalization through the shared + pseudocolor-mesh path, including an opaque default and retained count + domains for logarithmic mappables. +- [ ] `hist2d` arbitrary custom normalization and `colorizer` support. - [x] `hexbin(C=..., reduce_C_function=...)`, `mincnt`, marginals, norm, colorizer and explicit vmin/vmax. - [x] `boxplot`: notches, custom whiskers, bootstrap, user medians, confidence intervals, cap visibility/width, autorange and component properties. -- [x] `bxp`: component style parity, labels/ticks, cap widths and returned - component geometry. +- [x] `bxp`: component styles, statistics labels/ticks, cap widths, scalar or + per-box legend labels, and mutable filled patch boxes. - [x] `violinplot`/`violin`: bandwidth methods, quantiles, side, extrema, - points and component styling. + points, cycling face/line colors, color-alpha pairs and mutable body + styling. - [x] `ecdf`: exact weights/complementary/orientation/compression behavior and returned Artist parity. @@ -352,8 +356,10 @@ method accepts the call. normalize behavior, text properties and wedge properties. - [x] `table`: cell/row/column alignment, placement, edges, sizing, colors and mutable cell objects. -- [x] Spectral methods: window, detrending, sides, padding, frequency scaling, - modes, scale and return-value parity. +- [x] Spectral methods provide the native real-valued Hann-windowed defaults. +- [ ] Spectral callable windows/detrending, independent `pad_to`, explicit + sides/frequency scaling, complex inputs, modes and complete return-value + parity. These remain acceptance debt for `statistics/psd_demo.py`. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container behavior, hatches, orientation and baselines. - [x] `quiver`: units, head geometry, pivots, angles, scaling, norm, z-order and diff --git a/tests/pyplot/test_box_violin_default_compat.py b/tests/pyplot/test_box_violin_default_compat.py index bc28eba0..7b1b5e28 100644 --- a/tests/pyplot/test_box_violin_default_compat.py +++ b/tests/pyplot/test_box_violin_default_compat.py @@ -49,6 +49,70 @@ def test_default_boxplot_returns_one_empty_flier_handle_per_group() -> None: assert all(len(artist._entry["x"]) == 0 for artist in result["fliers"]) +def test_patch_boxplot_returns_mutable_filled_boxes_with_labels() -> None: + _fig, ax = plt.subplots() + result = ax.bxp( + [ + { + "med": 2.0, + "q1": 1.0, + "q3": 3.0, + "whislo": 0.0, + "whishi": 4.0, + "fliers": [], + "label": "A", + }, + { + "med": 3.0, + "q1": 2.0, + "q3": 4.0, + "whislo": 1.0, + "whishi": 5.0, + "fliers": [], + "label": "B", + }, + ], + patch_artist=True, + boxprops={"facecolor": "bisque", "edgecolor": "navy", "linewidth": 2.0}, + label=["first", "second"], + ) + + assert [box._entry["factory"] for box in result["boxes"]] == [ + "triangle_mesh", + "triangle_mesh", + ] + assert all(box._entry["kwargs"]["color"] == "bisque" for box in result["boxes"]) + assert all(box._entry["kwargs"]["stroke"] == "navy" for box in result["boxes"]) + assert ax._axis_props("x")["tick_labels"] == ["A", "B"] + assert ax.get_legend_handles_labels()[1] == ["first", "second"] + + first = result["boxes"][0] + first.set_facecolor("tomato") + first.set_edgecolor("black") + first.set_linewidth(3.0) + np.testing.assert_allclose(first.get_facecolor(), [[1.0, 99 / 255, 71 / 255, 1.0]]) + np.testing.assert_allclose(first.get_edgecolor(), [[0.0, 0.0, 0.0, 1.0]]) + np.testing.assert_array_equal(first.get_linewidth(), [3.0]) + + +def test_boxplot_patch_mutation_matches_custom_fill_gallery_idiom() -> None: + _fig, ax = plt.subplots() + result = ax.boxplot( + [[1.0, 2.0, 3.0], [2.0, 3.0, 4.0]], + patch_artist=True, + tick_labels=["peaches", "oranges"], + ) + + for patch, color in zip(result["boxes"], ["peachpuff", "orange"], strict=True): + patch.set_facecolor(color) + + assert [patch._entry["kwargs"]["color"] for patch in result["boxes"]] == [ + "rgba(255,218,185,1)", + "rgba(255,165,0,1)", + ] + assert ax._axis_props("x")["tick_labels"] == ["peaches", "oranges"] + + @pytest.mark.parametrize( ("bw_method", "factor"), [ @@ -107,6 +171,45 @@ def test_violinplot_returns_one_joined_body_per_group_and_renders() -> None: assert np.count_nonzero(np.any(pixels[..., :3] < 242, axis=-1)) > 500 +def test_violinplot_cycles_explicit_colors_and_returns_mutable_bodies() -> None: + _fig, ax = plt.subplots() + result = ax.violinplot( + [ + [0.0, 1.0, 2.0], + [1.0, 2.0, 4.0], + [2.0, 3.0, 5.0], + ], + facecolor=[("yellow", 0.3), ("blue", 0.4)], + linecolor=["black", "red"], + ) + + assert [body._entry["kwargs"]["color"] for body in result["bodies"]] == [ + "rgba(255,255,0,0.3)", + "rgba(0,0,255,0.4)", + "rgba(255,255,0,0.3)", + ] + assert all(body._entry["kwargs"]["opacity"] == 1.0 for body in result["bodies"]) + assert result["cmins"]._entry["kwargs"]["color"] == ["black", "red", "black"] + + body = result["bodies"][0] + body.set_edgecolor("black") + body.set_linewidth(1.5) + body.set_alpha(0.8) + assert body._entry["kwargs"]["stroke"] == "rgba(0,0,0,1)" + assert body._entry["kwargs"]["stroke_width"] == pytest.approx(1.5) + assert body._entry["kwargs"]["opacity"] == pytest.approx(0.8) + + +def test_default_violin_body_keeps_artist_alpha_after_facecolor_mutation() -> None: + _fig, ax = plt.subplots() + body = ax.violinplot([[0.0, 1.0, 2.0]])["bodies"][0] + + body.set_facecolor("red") + + assert body._entry["kwargs"]["color"] == "rgba(255,0,0,1)" + assert body._entry["kwargs"]["opacity"] == pytest.approx(0.3) + + def test_native_core_box_and_violin_marks_keep_their_fast_paths() -> None: figure = Figure() figure.box([[1.0, 2.0, 3.0], [2.0, 4.0, 8.0]]) diff --git a/tests/pyplot/test_statistics_numeric_regressions.py b/tests/pyplot/test_statistics_numeric_regressions.py index 61102675..59142347 100644 --- a/tests/pyplot/test_statistics_numeric_regressions.py +++ b/tests/pyplot/test_statistics_numeric_regressions.py @@ -79,6 +79,53 @@ def test_uniform_multihist_keeps_a_scalar_swatch_width_for_legend() -> None: assert ax.legend() is not None +def test_hist2d_lognorm_uses_opaque_mesh_and_retains_count_domain() -> None: + class LogNorm: + def __init__(self) -> None: + self.vmin: float | None = None + self.vmax: float | None = None + + def __call__(self, values: object) -> np.ma.MaskedArray: + source = np.ma.asarray(values, dtype=np.float64) + positive = source.compressed() + positive = positive[positive > 0.0] + if self.vmin is None: + self.vmin = float(positive.min()) + if self.vmax is None: + self.vmax = float(positive.max()) + masked = np.ma.masked_less_equal(source, 0.0) + if self.vmin == self.vmax: + return np.ma.zeros(masked.shape) + return (np.ma.log(masked) - np.log(self.vmin)) / (np.log(self.vmax) - np.log(self.vmin)) + + x = np.asarray([0.1, 0.2, 0.3, 1.2, 1.3, 2.4]) + y = np.asarray([0.1, 0.2, 0.3, 1.2, 1.3, 2.4]) + _fig, ax = plt.subplots() + + counts, xedges, yedges, image = ax.hist2d( + x, + y, + bins=3, + range=((0.0, 3.0), (0.0, 3.0)), + norm=LogNorm(), + ) + + np.testing.assert_array_equal(xedges, [0.0, 1.0, 2.0, 3.0]) + np.testing.assert_array_equal(yedges, [0.0, 1.0, 2.0, 3.0]) + np.testing.assert_array_equal( + counts, + [[3.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]], + ) + assert image._entry["factory"] == "heatmap" + assert image._entry["kwargs"]["opacity"] == pytest.approx(1.0) + assert image._entry["_mpl_domain"] == (1.0, 3.0) + assert image._entry["_mpl_norm_scale"] == "log" + np.testing.assert_array_equal(image._entry["source_z"], counts.T) + normalized = np.asarray(image._entry["args"][0]) + assert np.isnan(normalized[counts.T == 0.0]).all() + assert np.isfinite(normalized[counts.T > 0.0]).all() + + def test_spectral_defaults_match_matplotlib_311_detrend_none() -> None: values = np.arange(32.0) + 10.0 paired = values * 2.0 + 3.0