diff --git a/.issueflows/03-solved-issues/issue639_original.md b/.issueflows/03-solved-issues/issue639_original.md new file mode 100644 index 00000000..36378fa1 --- /dev/null +++ b/.issueflows/03-solved-issues/issue639_original.md @@ -0,0 +1,25 @@ +# Issue #639: Matplotlib backend; retire SeabornPlotBuilder; unify backend= + +Source: https://github.com/jepegit/cellpy/issues/639 + +## Original issue text + +## Context + +Part of epic #567 (Stage 1 — Spec pipeline for `summary_plot`). Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md`. + +## Scope + +Add `cellpy/plotting/backends/mpl.py` that renders the same `FigureSpec` (seaborn used only for palette/style helpers, not as a separate backend). Delete `SeabornPlotBuilder`. Public API: `backend="plotly"|"matplotlib"` on `summary_plot`; keep `interactive=` as a `warn_once` alias registered in `DEPRECATIONS.md` (removal 2.1). + +## Acceptance + +- Matplotlib summary oracle cases green and describe to the same structural shape family-for-family as plotly where the snapshot already compares them. +- No `SeabornPlotBuilder` class remains. +- Calling `interactive=True/False` warns once and maps to the right backend. + +## Depends on + +#638 + +Part of epic #567. diff --git a/.issueflows/03-solved-issues/issue639_plan.md b/.issueflows/03-solved-issues/issue639_plan.md new file mode 100644 index 00000000..13a1fe12 --- /dev/null +++ b/.issueflows/03-solved-issues/issue639_plan.md @@ -0,0 +1,92 @@ +# Issue #639 — Plan: matplotlib backend; retire SeabornPlotBuilder; unify `backend=` + +## Goal + +Add `cellpy/plotting/backends/mpl.py` that renders the same summary `(frame, FigureSpec)` as plotly, delete `SeabornPlotBuilder`, and switch public `summary_plot` to `backend="plotly"|"matplotlib"` with `interactive=` as a `warn_once` alias (removal 2.1). + +## Constraints + +- Plan of record: [`architecture-plan/cellpy2-plotting-redesign-plan.md`](../../../architecture-plan/cellpy2-plotting-redesign-plan.md) §3.1 / Phase 2 item 4; epic [`.issueflows/05-epics/epic567_plan.md`](../05-epics/epic567_plan.md) Stage 1 issue 4. +- Depends on #638 (merged): prepare → `FigureSpec` → `PlotlyBackend.render` is the interactive path; static path still uses `SeabornPlotBuilder` on the same prepared frame ([`plotting-prepare.md`](../04-designs-and-guides/plotting-prepare.md), [`plotting-backends.md`](../04-designs-and-guides/plotting-backends.md)). +- Oracle gate: `tests/test_figure_specs.py` matplotlib summary cases stay green **without** intentional `figure_specs.json` regen unless a proven structural change is required. +- Scope is **`summary_plot` only** — do not retarget `raw_plot` / `cycles_plot` / `cycle_info_plot` `interactive=` (Stage 2). +- Not yolo — static-output engine change; seaborn-loyalist surface. + +### Prior art + +- `SeabornPlotBuilder` — `cellpy/utils/plotutils.py` (~1002–1856, ~850 lines, 14 methods). Owns `sns.relplot` construction, formation facet columns, axis info dicts, legend convert, line hooks. Returns `sns_fig.figure` (already a matplotlib `Figure`). +- `summary_plot` orchestration (~2190–2218) — `interactive` → `PlotlyBackend` vs `SeabornPlotBuilder`. +- `SummaryPlotConfig.interactive` / seaborn styling fields (`seaborn_palette`, `seaborn_style`, `seaborn_line_hooks`) — keep styling knobs; replace the bool switch with `backend`. +- `PlotlyBackend.render(frame, spec)` — contract to mirror; mpl reads the same `spec.extras` (`prepared_data_info`, and any mpl-needed keys we add under `render` / top-level extras). +- `tests/figure_spec_support.py` — menu uses `interactive=True/False` and `needs_seaborn=True` for matplotlib summary cases; `_describe_matplotlib` already structural. +- `cellpy._deprecation.warn_once` + `DEPRECATIONS.md` — pattern used by `summary_plot_legacy` (#596). +- Architecture policy: two backends (`plotly` | `matplotlib`); seaborn = styling inside mpl, not a third backend name. +- Toolbox / graphify: nothing relevant. + +## Approach + +1. **`cellpy/plotting/backends/mpl.py` — `MatplotlibBackend`** + - Implement `Backend.render(frame, spec) -> matplotlib.figure.Figure`. + - **Mechanical port** of `SeabornPlotBuilder.build_plot` (+ helpers) into this class/module: keep `sns.relplot` / `sns.set_style` / `sns.set_palette` as the faceting+styling engine so oracle structure stays stable. Seaborn is not a public backend name. + - Pull layout inputs from `spec.extras` (`prepared_data_info`, labels, row/col ids) rather than a parallel prepare path. Cell-bound bits still needed for titles/units can come from extras already populated by prepare (#638) or a small `extras['cell_name']` / capacity fields — avoid re-importing preparer logic. + - Soft-fail if seaborn missing (same warn-and-return-data behaviour as today) so optional-deps story does not regress. + +2. **`get_backend(name)`** + - Add thin resolver in `cellpy/plotting/backends/__init__.py` (and optional re-export): `"plotly"` → `PlotlyBackend`, `"matplotlib"` → `MatplotlibBackend`; unknown → clear `ValueError`. + +3. **Public `summary_plot` API** + - Add `backend: Optional[str] = None`. + - Change `interactive` default to `None` (sentinel). Resolution order: + 1. If `interactive is not None`: `warn_once("summary_plot(interactive=...)", 'backend="plotly"|"matplotlib"', removal="2.1")` and map `True→"plotly"`, `False→"matplotlib"` when `backend` is unset; if both set and conflict, prefer `backend` after warning (document in status). + 2. If `backend is None`: default `"plotly"`. + 3. Validate via `get_backend`. + - Orchestration becomes: prepare → `get_backend(backend).render(frame, spec)`. + - Update docstring examples to prefer `backend=`; note `interactive=` deprecated. + - Mirror fields on `SummaryPlotConfig` (`backend`, `interactive` optional). + - Regenerate / ensure `DEPRECATIONS.md` picks up the new `warn_once` name (run `uv run python -m cellpy._deprecation` or the project’s documented regen path when the call site is live). + +4. **Delete `SeabornPlotBuilder`** + - Remove the class from `plotutils.py`. No shim class. Grep tests/docs for the name and update. + +5. **Tests / oracle harness** + - Point `figure_spec_support` summary matplotlib cases at `backend="matplotlib"` (and plotly at `backend="plotly"`); keep `needs_seaborn` (or rename lightly) while mpl render still imports seaborn. + - Add focused tests: `get_backend` smoke; `interactive=False/True` emits `DeprecationWarning` once and selects the right backend; `SeabornPlotBuilder` absent; oracle + `tests/test_plotutils_summary_plot.py` green (migrate call sites to `backend=` to avoid warning noise, plus 1–2 explicit interactive-deprecation cases). + - Prefer **no** `figure_specs.json` regen; if matplotlib describe shape drifts unavoidably, regen in the same commit with a status note. + +6. **Design notes** + - Update [`plotting-backends.md`](../04-designs-and-guides/plotting-backends.md) and [`plotting-prepare.md`](../04-designs-and-guides/plotting-prepare.md): mpl owns static summary render; seaborn is styling-only; `backend=` is the public switch. + +## Files to touch + +| Path | Change | +|---|---| +| `cellpy/plotting/backends/mpl.py` | **new** — `MatplotlibBackend` (ported seaborn builder) | +| `cellpy/plotting/backends/__init__.py` | export `MatplotlibBackend`, `get_backend` | +| `cellpy/plotting/__init__.py` | light re-exports / docstring | +| `cellpy/utils/plotutils.py` | `backend=` + interactive sentinel; delete `SeabornPlotBuilder`; thin orchestrate via `get_backend` | +| `DEPRECATIONS.md` | regen row for `summary_plot(interactive=...)` | +| `tests/figure_spec_support.py` | menu kwargs → `backend=` | +| `tests/test_plotutils_summary_plot.py` | migrate to `backend=`; add deprecation cases | +| `tests/test_summary_prepare.py` / new `tests/test_mpl_backend.py` | backend selection + builder-gone | +| `.issueflows/04-designs-and-guides/plotting-*.md` | document flip | + +## Test strategy + +```bash +uv sync --extra batch +MPLBACKEND=Agg uv run pytest tests/test_mpl_backend.py tests/test_summary_prepare.py tests/test_plotutils_summary_plot.py tests/test_figure_specs.py -q +MPLBACKEND=Agg uv run pytest -m essential --ignore=tests/test_arbin_variants_two_stage.py +``` + +Parity focus: all summary families × matplotlib oracle entries; formation on/off; `interactive=` warn+map; no `SeabornPlotBuilder`. + +## Open questions + +1. **How much of seaborn stays inside `MatplotlibBackend`?** — **Recommend:** keep `sns.relplot` + style/palette helpers for this PR (oracle-stable “styling+facet engine”). Alternative: rewrite with plain `matplotlib` Axes immediately — higher blast radius; defer unless Accept insists. +2. **`interactive=` default sentinel** — **Recommend:** `interactive: Optional[bool] = None` so quiet default is `backend="plotly"` with no warning; only explicit `interactive=` warns. Alternative: keep `interactive=True` default and warn always — too noisy. +3. **Both `backend=` and `interactive=` passed** — **Recommend:** warn on `interactive=`, honour `backend=` if set. Alternative: error on conflict — harsher for transitional callers. +4. **Escape hatch to old builder** — **Recommend:** none (issue requires class deleted). Architecture’s `CELLPY_LEGACY_SUMMARY_PLOT` is obsolete once prepare path is the only prepare. + +## Scope check + +One Stage-1 slice: mpl backend module + API unify + delete seaborn builder. Fits a single PR. Follow-ups already in epic: Stage 2 ports (`cycles_plot`, etc.) reuse `backend=` / backends. diff --git a/.issueflows/03-solved-issues/issue639_status.md b/.issueflows/03-solved-issues/issue639_status.md new file mode 100644 index 00000000..ff684ceb --- /dev/null +++ b/.issueflows/03-solved-issues/issue639_status.md @@ -0,0 +1,21 @@ +# Issue #639 — Status + +- [x] Done + +## What's done + +- Plan accepted (2026-07-23). +- Branch `cursor/639-mpl-backend-4efd`; draft PR #645. +- Added `cellpy/plotting/backends/mpl.py` (`MatplotlibBackend.render`). +- Added `get_backend()`; flipped `summary_plot` to `backend=` with + `interactive=` as `warn_once` alias (removal 2.1); seeded in + `_deprecation._seed_known_deprecations` + `DEPRECATIONS.md`. +- Deleted `SeabornPlotBuilder`. +- Migrated oracle/summary tests to `backend=`; added `tests/test_mpl_backend.py`. +- Design notes updated (`plotting-backends.md`, `plotting-prepare.md`). +- Verified green on close: mpl + figure-specs (61); essential (556). +- HISTORY Unreleased bullet added; issue docs archived to `03-solved-issues`. + +## Remaining work + +- None (post-merge: `/iflow-cleanup`). diff --git a/.issueflows/04-designs-and-guides/plotting-backends.md b/.issueflows/04-designs-and-guides/plotting-backends.md index 521c501b..dcb179e5 100644 --- a/.issueflows/04-designs-and-guides/plotting-backends.md +++ b/.issueflows/04-designs-and-guides/plotting-backends.md @@ -2,28 +2,23 @@ ## Context -`PlotlyPlotBuilder` had four nearly-copied -`_configure_formation_{1,2,3,4}_rows` methods (~350 lines) that set facet -axis domains/matches/ranges for formation figures. Epic #567 Stage 1 needs -one layout engine and a real prepare→spec→render path for `summary_plot`. +Epic #567 Stage 1 needs one layout engine and prepare→spec→render for +`summary_plot`, with two public backends (`plotly` | `matplotlib`). ## Decision - **Formation layout lives in** `cellpy/plotting/backends/plotly.py` (`configure_formation_layout`). -- **Per-row-count methods are deleted.** Specials stay as parameters/helpers: - N=1 annotation y; optional `top_row_label` domains; - `configure_fullcell_standard_domains` for fullcell 4-row titles. -- **`Backend` protocol** in `backends/base.py`; public interactive - `summary_plot` calls `PlotlyBackend.render(frame, spec)` (#638). -- **No-formation path** also lives on `PlotlyBackend` (layout knobs come from - `FigureSpec.extras['render']` produced by prepare). -- **`PlotlyPlotBuilder` deleted.** `CELLPY_SUMMARY_PLOTLY_SPEC` provisional - dual-path removed — single interactive path. -- **`SeabornPlotBuilder`** remains until #639; it consumes the same prepared - frame / `prepared_data_info` from prepare. +- **`Backend` protocol** in `backends/base.py`; `get_backend(name)` resolves + `"plotly"` / `"matplotlib"`. +- **Interactive path:** `PlotlyBackend.render(frame, spec)` (#638). +- **Static path:** `MatplotlibBackend.render(frame, spec)` (#639). Seaborn is + used only for palette/style/faceting helpers (`relplot`); it is **not** a + public backend name. `SeabornPlotBuilder` is deleted. +- **Public switch:** `summary_plot(..., backend="plotly"|"matplotlib")`. + `interactive=` is a `warn_once` alias (removal 2.1). ## Links -- Issues #638, #637, #636; epic #567 +- Issues #639, #638, #637, #636; epic #567 - Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md` §3.1 diff --git a/.issueflows/04-designs-and-guides/plotting-prepare.md b/.issueflows/04-designs-and-guides/plotting-prepare.md index 139ea5f5..3c5a72a9 100644 --- a/.issueflows/04-designs-and-guides/plotting-prepare.md +++ b/.issueflows/04-designs-and-guides/plotting-prepare.md @@ -3,8 +3,8 @@ ## Context `SummaryPlotDataPreparer` lived in `cellpy/utils/plotutils.py` and fed both -`PlotlyPlotBuilder` and `SeabornPlotBuilder`. Epic #567 Stage 1 needs -prepare → `FigureSpec` → backend.render as the only summary path. +plotly and seaborn builders. Epic #567 Stage 1 needs prepare → `FigureSpec` → +backend.render as the only summary path. ## Decision @@ -12,15 +12,16 @@ prepare → `FigureSpec` → backend.render as the only summary path. `prepare(ctx, family, config) -> (frame, FigureSpec)`. - **`SummaryPlotDataPreparer`** moved there (implementation detail); deleted from `plotutils`. -- **`FigureSpec.extras`** carries `prepared_data_info` (seaborn bridge) and - `render` (plotly knobs including precomputed formation / no-formation - layout). First-class panel/axis fields grow as later issues need them. +- **`FigureSpec.extras`** carries `prepared_data_info` and `render` (plotly + knobs including precomputed formation / no-formation layout). For the + matplotlib backend, `summary_plot` also attaches live `config` / `cell` on + `extras` so seaborn styling knobs remain available without a second prepare. - **`CellContext`** in `cellpy/plotting/context.py` is the thin cell adapter; BatchContext waits for collectors rebase. -- Public `summary_plot` stays in `plotutils` but only orchestrates - context → registry → prepare → render. +- Public `summary_plot` stays in `plotutils` and orchestrates + context → registry → prepare → `get_backend(backend).render`. ## Links -- Issue #638; epic #567 +- Issues #639, #638; epic #567 - Related: `plotting-registry.md`, `plotting-backends.md` diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 631e9772..57385f2c 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -18,4 +18,5 @@ uv run python -m cellpy._deprecation | `legacy header attribute access (headers_normal / _summary / _step_table)` | `c.schema.raw / c.schema.steps / c.schema.summary` | 2.0 | 2.1 | | `make_new_cell` | `CellpyCell.vacant` | 2.0 | 2.1 | | `plotutils.summary_plot_legacy` | `cellpy.utils.plotutils.summary_plot (same figures, same options)` | 2.0 | 2.1 | +| `summary_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 | | `the 'dq' column of the ica output frame` | `the 'dqdv' column of the same frame` | 2.0 | 2.1 | diff --git a/HISTORY.md b/HISTORY.md index e0aa4f80..abdd3d71 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,8 @@ ## [Unreleased] +* Matplotlib backend; retire SeabornPlotBuilder; unify backend= (#639). + * Port summary prepare path and flip summary_plot to prepare→spec→render (#638). * Generic plotly panel/formation layout backend (#637). diff --git a/cellpy/_deprecation.py b/cellpy/_deprecation.py index 44d3bd0d..6495caba 100644 --- a/cellpy/_deprecation.py +++ b/cellpy/_deprecation.py @@ -155,6 +155,12 @@ def _seed_known_deprecations() -> None: "cellpy.utils.plotutils.summary_plot (same figures, same options)", removal="2.1", ) + # Stage 1 (#639): interactive= is a warn_once alias for backend=. + _register( + "summary_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", + ) if __name__ == "__main__": diff --git a/cellpy/plotting/__init__.py b/cellpy/plotting/__init__.py index 1b31039b..62a59ef5 100644 --- a/cellpy/plotting/__init__.py +++ b/cellpy/plotting/__init__.py @@ -19,13 +19,17 @@ The old locations re-export from here, so nothing that imported them breaks. Public ``summary_plot`` still lives in ``plotutils`` but runs -prepare → spec → ``PlotlyBackend.render`` for the interactive path (#638); -``SeabornPlotBuilder`` remains until #639. +prepare → spec → ``get_backend(...).render`` (#638 / #639). """ from __future__ import annotations -from cellpy.plotting.backends import Backend, PlotlyBackend +from cellpy.plotting.backends import ( + Backend, + MatplotlibBackend, + PlotlyBackend, + get_backend, +) from cellpy.plotting.context import CellContext, from_source from cellpy.plotting.figures import ( load_figure, @@ -50,12 +54,14 @@ "Backend", "CellContext", "FigureSpec", + "MatplotlibBackend", "PanelSpec", "PlotFamily", "PlotlyBackend", "_register_family", "families", "from_source", + "get_backend", "get_family", "legend_replacer", "load_figure", diff --git a/cellpy/plotting/backends/__init__.py b/cellpy/plotting/backends/__init__.py index 26a54348..cf0f7815 100644 --- a/cellpy/plotting/backends/__init__.py +++ b/cellpy/plotting/backends/__init__.py @@ -1,8 +1,11 @@ -"""Plotting backends (prepare → spec → render) — epic #567 / #637–#638.""" +"""Plotting backends (prepare → spec → render) — epic #567 / #637–#639.""" from __future__ import annotations +from typing import Any + from cellpy.plotting.backends.base import Backend +from cellpy.plotting.backends.mpl import MatplotlibBackend from cellpy.plotting.backends.plotly import ( PlotlyBackend, configure_formation_layout, @@ -11,7 +14,21 @@ __all__ = [ "Backend", + "MatplotlibBackend", "PlotlyBackend", "configure_formation_layout", "configure_fullcell_standard_domains", + "get_backend", ] + + +def get_backend(name: str) -> Any: + """Return a backend instance for *name* (``plotly`` or ``matplotlib``).""" + key = (name or "").strip().lower() + if key == "plotly": + return PlotlyBackend() + if key == "matplotlib": + return MatplotlibBackend() + raise ValueError( + f"unknown plotting backend {name!r} (known: plotly, matplotlib)" + ) diff --git a/cellpy/plotting/backends/mpl.py b/cellpy/plotting/backends/mpl.py new file mode 100644 index 00000000..c5d56d87 --- /dev/null +++ b/cellpy/plotting/backends/mpl.py @@ -0,0 +1,917 @@ +"""Matplotlib backend for prepare → spec → render (#639). + +Seaborn provides palette/style/faceting helpers only; the public backend name +is ``matplotlib``. +""" + +from __future__ import annotations + +import logging +import warnings +from typing import Any, Optional + +import numpy as np +import pandas as pd + +from cellpy.exceptions import UnitsError +from cellpy.plotting.spec import FigureSpec +from cellpy.units import units_label + +logger = logging.getLogger(__name__) + + +def _seaborn_available() -> bool: + import importlib.util + + return importlib.util.find_spec("seaborn") is not None + + +def _capacity_unit(c: Any, mode: str = "gravimetric") -> str: + try: + return units_label("charge", mode, units=c.cellpy_units) + except UnitsError: + return "-" + + +def _seaborn_top_row_label(y: str) -> Optional[str]: + if y.endswith("_efficiency"): + return "Coulombic\nEfficiency (%)" + if y.endswith("_with_rate"): + return "C-rate\n(1/h)" + return None + + +def _has_special_top_row(y: str) -> bool: + return y.endswith("_efficiency") or y.endswith("_with_rate") + + +class MatplotlibBackend: + """Matplotlib backend for summary ``render(frame, spec)`` (#639). + + Seaborn is used only for palette/style/faceting helpers (``relplot``), + not as a public backend name. Ported from ``SeabornPlotBuilder``. + """ + + name = "matplotlib" + + def __init__(self) -> None: + self.y_header = "value" + self.color = "variable" + self.row = "row" + self.col_id = "cycle_type" + + def render(self, frame: Any, spec: FigureSpec) -> Any: + """Render a tidy frame according to *spec* (matplotlib Figure).""" + extras = dict(spec.extras or {}) + config = extras.get("config") + c = extras.get("cell") + if config is None or c is None: + raise ValueError( + "FigureSpec.extras must include 'config' and 'cell' for " + "MatplotlibBackend.render" + ) + prepared_data_info = dict(extras.get("prepared_data_info") or {}) + prepared_data_info.setdefault("number_of_rows", extras.get("number_of_rows")) + prepared_data_info.setdefault( + "x_label", getattr(spec.x_axis, "label", None) + ) + prepared_data_info.setdefault("y_label", extras.get("y_label")) + additional_kwargs = dict(getattr(config, "additional_kwargs", {}) or {}) + return self._build_plot( + frame, prepared_data_info, config, additional_kwargs, c + ) + + def _build_plot( + self, + data: Any, + prepared_data_info: dict, + config: Any, + additional_kwargs: dict, + c: Any, + ) -> Any: + """Build matplotlib figure via seaborn styling/faceting helpers. + + Args: + """ + if not _seaborn_available(): + warnings.warn( + "seaborn not available, returning only the data so that you can plot it yourself instead" + ) + return data + + import seaborn as sns + import matplotlib.pyplot as plt + + # Extract seaborn-specific parameters + seaborn_facecolor = additional_kwargs.pop("seaborn_facecolor", "#EAEAF2") + seaborn_edgecolor = additional_kwargs.pop("seaborn_edgecolor", "black") + seaborn_style_dict_default = { + "axes.facecolor": seaborn_facecolor, + "axes.edgecolor": seaborn_edgecolor, + } + seaborn_style_dict = additional_kwargs.pop( + "seaborn_style_dict", seaborn_style_dict_default + ) + seaborn_marker_size = additional_kwargs.pop("seaborn_marker_size", 7) + xlim_formation = additional_kwargs.pop( + "xlim_formation", (0.6, config.formation_cycles + 0.4) + ) + + # Set default title if not provided + title = config.title + if title is None: + title = f"Summary {c.cell_name}" + + x = config.x if config.x is not None else c.schema.summary.cycle_num + y = config.y + number_of_rows = prepared_data_info["number_of_rows"] + x_label = prepared_data_info["x_label"] + y_label = prepared_data_info["y_label"] + max_cycle = prepared_data_info["max_cycle"] + max_val_normalized_col = prepared_data_info["max_val_normalized_col"] + + # Set up seaborn + sns.set_style(config.seaborn_style, seaborn_style_dict) + sns.set_palette(config.seaborn_palette) + sns.set_context(additional_kwargs.pop("seaborn_context", "notebook")) + + # Configure facet and gridspec kwargs + facet_kws = dict(despine=False, sharex=False, sharey=False) + gridspec_kws = dict(hspace=0.07) + + # Configure columns for formation cycles + col_id = None + if config.show_formation and self.col_id in data.columns: + additional_kwargs["col"] = self.col_id + number_of_cols = 2 + col_id = self.col_id + gridspec_kws["width_ratios"] = additional_kwargs.pop("width_ratios", [1, 6]) + gridspec_kws["wspace"] = additional_kwargs.pop("wspace", 0.02) + else: + number_of_cols = 1 + + # Configure rows + # Note: number_of_rows from prepared_data_info is the expected number, + # but we need to verify it matches the actual data + row_id = None + if not config.split: + number_of_rows = 1 + logging.debug(f"split=False, setting number_of_rows=1") + else: + row_id = self.row + if self.row in data.columns: + additional_kwargs["row"] = self.row + actual_number_of_rows = data[self.row].nunique() + # Use the actual number from data, but log if it differs from expected + if actual_number_of_rows != number_of_rows: + logging.warning( + f"Number of rows mismatch: expected {number_of_rows} from data preparer, " + f"but data has {actual_number_of_rows} unique row values. Using {actual_number_of_rows}." + ) + number_of_rows = actual_number_of_rows + logging.debug( + f"split=True, row column '{self.row}' found, number_of_rows={number_of_rows}" + ) + else: + # If split=True but row column doesn't exist, fall back to 1 row + logging.warning( + f"split=True but row column '{self.row}' not found in data. " + f"Expected {number_of_rows} rows but falling back to 1 row." + ) + number_of_rows = 1 + logging.debug( + f"split=True but row column '{self.row}' not found, setting number_of_rows=1" + ) + + # Calculate plot properties + plot_type = ( + "fullcell_standard" if y.startswith("fullcell_standard_") else "default" + ) + seaborn_plot_height, seaborn_plot_aspect = ( + self._calculate_seaborn_plot_properties( + number_of_rows, number_of_cols, plot_type + ) + ) + seaborn_plot_height = additional_kwargs.pop( + "seaborn_plot_height", seaborn_plot_height + ) + seaborn_plot_aspect = additional_kwargs.pop( + "seaborn_plot_aspect", seaborn_plot_aspect + ) + + # Calculate axis limits + eff_lim = config.ce_range + if eff_lim is None: + eff_lim = self._calculate_efficiency_limits(data) + + x_range = config.x_range + if x_range is None: + cycle_range = max_cycle - config.formation_cycles + if cycle_range <= 0: + cycle_range = 10 # arbitrary value + x_range = ( + config.formation_cycles + 1 - 0.02 * abs(cycle_range), + max_cycle + 0.02 * abs(cycle_range), + ) + + y_range = config.y_range + if y_range is None: + y_range = self._calculate_y_range(data) + + # Build info_dicts for axis configuration + info_dicts = self._build_axis_info_dicts( + y, + config, + number_of_rows, + x_range, + y_range, + eff_lim, + xlim_formation, + x_label, + y_label, + max_val_normalized_col, + c, + ) + + # Configure facet_kws based on plot type. ``_efficiency`` and + # ``_with_rate`` share the same row-0-is-different layout: + # disable shared y-axis and give the top row a smaller height. + is_efficiency_plot = y.endswith("_efficiency") + is_special_top_row = _has_special_top_row(y) + if is_special_top_row: + facet_kws["sharey"] = False + if number_of_rows == 2: + gridspec_kws["height_ratios"] = [1, 4] + else: + logging.debug( + f"Special-top-row plot with {number_of_rows} rows - not setting height_ratios" + ) + + facet_kws["gridspec_kws"] = gridspec_kws + + # Log configuration for debugging + logging.debug("Seaborn plot configuration:") + logging.debug( + f" y={y}, split={config.split}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" + ) + logging.debug(f" row_id={row_id}, col_id={col_id}") + logging.debug(f" is_efficiency_plot={is_efficiency_plot}") + logging.debug(f" gridspec_kws={gridspec_kws}") + logging.debug(f" additional_kwargs keys: {list(additional_kwargs.keys())}") + if config.verbose: + logging.info("Seaborn plot configuration:") + logging.info( + f" y={y}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" + ) + logging.info(f" row_id={row_id}, col_id={col_id}") + logging.info(f" is_efficiency_plot={is_efficiency_plot}") + logging.info(f" gridspec_kws={gridspec_kws}") + logging.info(f" additional_kwargs keys: {list(additional_kwargs.keys())}") + + # Create the plot + # Suppress tight_layout warning from seaborn when using gridspec_kws + # (seaborn calls tight_layout internally on axes that may be incompatible) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=".*tight_layout.*", + category=UserWarning, + module="seaborn.axisgrid", + ) + sns_fig = sns.relplot( + data=data, + x=x, + y=self.y_header, + hue=self.color, + height=seaborn_plot_height, + aspect=seaborn_plot_aspect, + kind="line", + marker="o" if config.markers else None, + legend=config.show_legend, + **additional_kwargs, + facet_kws=facet_kws, + ) + + sns_fig.set_axis_labels(x_label, y_label) + + # Convert legend labels if requested + if config.auto_convert_legend_labels and config.show_legend: + self._convert_legend_labels(sns_fig) + + # Set marker sizes + if config.markers: + for ax in sns_fig.axes.flat: + lines = ax.get_lines() + for line in lines: + line.set_markersize(seaborn_marker_size) + + # Apply line hooks if provided + if config.seaborn_line_hooks: + for ax in sns_fig.axes.flat: + lines = ax.get_lines() + for line in lines: + for hook, args, hook_kwargs in config.seaborn_line_hooks: + if hasattr(line, hook): + getattr(line, hook)(*args, **hook_kwargs) + + # Clean up axes and set title + fig = sns_fig.figure + self._clean_up_axis(fig, info_dicts=info_dicts, row_id=row_id, col_id=col_id) + fig.align_ylabels() + _hack_to_position_legend = {1: 0.97, 2: 0.95, 3: 0.92, 4: 0.92, 5: 0.92} + fig.suptitle(title, y=_hack_to_position_legend.get(number_of_rows, 0.92)) + + plt.close(fig) + return fig + + def _calculate_seaborn_plot_properties( + self, number_of_rows: int, number_of_cols: int, plot_type: str = "default" + ) -> tuple: + """Calculate seaborn plot height and aspect ratio.""" + if plot_type == "fullcell_standard": + _selector = { + (4, 1): (2.0, 4.0), + (4, 2): (2.0, 2.0), + } + else: + _selector = { + (1, 1): (4.0, 2.05), + (1, 2): (4.0, 1.0), + (2, 1): (2.8, 2.8), + (2, 2): (2.8, 1.4), + (3, 1): (3.0, 2.7), + (3, 2): (3.0, 1.35), + (4, 1): (3.0, 2.7), + (4, 2): (3.0, 1.35), + } + return _selector.get((number_of_rows, number_of_cols), (4.0, 1.8)) + + def _calculate_efficiency_limits(self, data: pd.DataFrame) -> list: + """Calculate efficiency axis limits from data.""" + eff_vals = ( + data.loc[data[self.color].str.contains("_efficiency"), self.y_header] + .pipe(pd.to_numeric, errors="coerce") + .dropna() + ) + if len(eff_vals) == 0: + return [0, 100] + eff_min, eff_max = eff_vals.min(), eff_vals.max() + return [eff_min - 0.05 * abs(eff_min), eff_max + 0.05 * abs(eff_max)] + + def _calculate_y_range(self, data: pd.DataFrame) -> list: + """Calculate y-axis range from data.""" + y_vals = ( + data.loc[~data[self.color].str.contains("_efficiency"), self.y_header] + .pipe(pd.to_numeric, errors="coerce") + .dropna() + ) + if len(y_vals) == 0: + return [0, 1] + min_value, max_value = y_vals.min(), y_vals.max() + return [ + min_value - 0.05 * abs(min_value), + max_value + 0.05 * abs(max_value), + ] + + def _build_axis_info_dicts( + self, + y: str, + config: Any, + number_of_rows: int, + x_range: tuple, + y_range: list, + eff_lim: Optional[list], + xlim_formation: tuple, + x_label: str, + y_label: str, + max_val_normalized_col: float, + c: Any, + ) -> list: + """Build info dictionaries for axis configuration.""" + info_dicts = [] + is_efficiency_plot = y.endswith("_efficiency") + is_fullcell_standard_plot = y.startswith("fullcell_standard_") + is_split_constant_voltage_plot = y.endswith("_split_constant_voltage") + + _efficiency_label = r"Efficiency (%)" + + if is_efficiency_plot: + info_dicts.extend( + self._build_efficiency_plot_info_dicts( + config, x_range, y_range, eff_lim, xlim_formation, _efficiency_label + ) + ) + elif is_split_constant_voltage_plot: + info_dicts.extend( + self._build_cv_split_info_dicts( + config, + number_of_rows, + x_range, + y_range, + config.cv_share_range, + xlim_formation, + y_label, + ) + ) + elif is_fullcell_standard_plot: + info_dicts.extend( + self._build_fullcell_standard_info_dicts( + config, + y, + x_range, + y_range, + eff_lim, + config.cv_share_range, + config.norm_range, + max_val_normalized_col, + xlim_formation, + c, + ) + ) + else: + info_dicts.extend( + self._build_standard_info_dicts( + config, + number_of_rows, + x_range, + y_range, + xlim_formation, + y_label, + top_row_ylabel=_seaborn_top_row_label(y), + ) + ) + + return info_dicts + + def _build_efficiency_plot_info_dicts( + self, + config: Any, + x_range: tuple, + y_range: list, + eff_lim: Optional[list], + xlim_formation: tuple, + efficiency_label: str, + ) -> list: + """Build info dicts for efficiency plots.""" + info_dicts = [] + if config.show_formation: + info_dicts.extend( + [ + dict( + ylabel=efficiency_label, + title="", + xlim=xlim_formation, + ylim=eff_lim, + row=0, + col="formation", + yticks=None, + xticks=False, + ), + dict( + ylabel="", + title="", + xlim=x_range, + ylim=eff_lim, + row=0, + col="standard", + yticks=False, + xticks=False, + ), + dict( + ylabel="", + title="", + xlim=xlim_formation, + ylim=y_range, + row=1, + col="formation", + yticks=None, + xticks=None, + ), + dict( + ylabel="", + title="", + xlim=x_range, + ylim=y_range, + row=1, + col="standard", + yticks=False, + xticks=None, + ), + ] + ) + else: + info_dicts.extend( + [ + dict( + ylabel=efficiency_label, + title="", + xlim=x_range, + ylim=eff_lim, + row=0, + col=None, + yticks=None, + xticks=False, + ), + dict( + ylabel="", + title="", + xlim=x_range, + ylim=y_range, + row=1, + col=None, + yticks=None, + xticks=None, + ), + ] + ) + return info_dicts + + def _build_cv_split_info_dicts( + self, + config: Any, + number_of_rows: int, + x_range: tuple, + y_range: list, + cv_share_range: Optional[list], + xlim_formation: tuple, + y_label: str, + ) -> list: + """Build info dicts for CV split plots.""" + info_dicts = [] + + # Row names for CV split plots when split=True + row_names = ["all", "without CV", "with CV"] + + # If split=False, we only have one row + if number_of_rows == 1: + _d = dict( + ylabel=y_label, + title="", + xlim=x_range, + ylim=cv_share_range or y_range, + row=None, + col=None, + yticks=None, + xticks=None, + ) + if config.show_formation: + _d["col"] = "standard" + _d["yticks"] = False + _d["ylabel"] = "" + info_dicts.append( + dict( + ylabel=y_label, + title="", + xlim=xlim_formation, + ylim=cv_share_range or y_range, + row=None, + col="formation", + yticks=None, + xticks=None, + ) + ) + info_dicts.append(_d) + else: + # Handle 3-row case (all, without CV, with CV) + for row_name in row_names[:number_of_rows]: + if config.show_formation: + # Standard column (second column) - no y-axis labels + info_dicts.append( + dict( + ylabel="", + title="", + xlim=x_range, + ylim=cv_share_range or y_range, + row=row_name, + col="standard", + yticks=False, + xticks=True if row_name == row_names[-1] else False, + ) + ) + # Formation column (first column) - with y-axis labels + info_dicts.append( + dict( + ylabel=y_label, + title="", + xlim=xlim_formation, + ylim=cv_share_range or y_range, + row=row_name, + col="formation", + yticks=True, + xticks=True if row_name == row_names[-1] else False, + ) + ) + else: + # No formation column, single column plot + info_dicts.append( + dict( + ylabel=y_label if row_name == row_names[0] else "", + title="", + xlim=x_range, + ylim=cv_share_range or y_range, + row=row_name, + col=None, + yticks=True if row_name == row_names[0] else None, + xticks=True if row_name == row_names[-1] else False, + ) + ) + + return info_dicts + + def _build_fullcell_standard_info_dicts( + self, + config: Any, + y: str, + x_range: tuple, + y_range: list, + eff_lim: Optional[list], + cv_share_range: Optional[list], + norm_range: Optional[list], + max_val_normalized_col: float, + xlim_formation: tuple, + c: Any, + ) -> list: + """Build info dicts for fullcell standard plots.""" + info_dicts = [] + capacity_unit = _capacity_unit(c, mode=y.split("_")[-1]) + ce_label = "Coulombic\nEfficiency (%)" + capacity_label = f"Capacity\n({capacity_unit})" + + loss_label = f"Capacity\nRetention\n({capacity_unit})" + if ( + config.fullcell_standard_normalization_type + and config.fullcell_standard_normalization_factor is not None + ): + _norm_label = f"[{config.fullcell_standard_normalization_scaler:.1f}/{config.fullcell_standard_normalization_factor:.1f} {capacity_unit}]" + loss_label = f"Capacity\nRetention (norm.)\n{_norm_label}" + else: + loss_label = f"Capacity\nRetention\n({capacity_unit})" + + cv_label = f"CV Capacity\n({capacity_unit})" + + if config.fullcell_standard_normalization_type is not False: + cum_loss_info_range = norm_range or [ + 0.0, + max( + max_val_normalized_col, + config.fullcell_standard_normalization_scaler, + ), + ] + else: + cum_loss_info_range = norm_range or y_range + + cv_info = dict( + title="", + xlim=x_range, + ylim=cv_share_range or y_range, + row=3, + col="standard", + yticks=False, + xticks=True, + ) + cum_loss_info = dict( + title="", + xlim=x_range, + ylim=cum_loss_info_range, + row=2, + col="standard", + yticks=False, + xticks=False, + ) + capacity_info = dict( + title="", + xlim=x_range, + ylim=y_range, + row=1, + col="standard", + yticks=False, + xticks=False, + ) + ce_info = dict( + title="", + xlim=x_range, + ylim=eff_lim, + row=0, + col="standard", + yticks=False, + xticks=False, + ) + + if not config.show_formation: + cv_info["ylabel"] = cv_label + cum_loss_info["ylabel"] = loss_label + capacity_info["ylabel"] = capacity_label + ce_info["ylabel"] = ce_label + cv_info["yticks"] = True + cum_loss_info["yticks"] = True + capacity_info["yticks"] = True + ce_info["yticks"] = True + + info_dicts.extend([cv_info, cum_loss_info, capacity_info, ce_info]) + + if config.show_formation: + info_dicts.extend( + [ + dict( + ylabel=cv_label, + title="", + xlim=xlim_formation, + ylim=cv_share_range or y_range, + row=3, + col="formation", + yticks=True, + xticks=True, + ), + dict( + ylabel=loss_label, + title="", + xlim=xlim_formation, + ylim=cum_loss_info_range, + row=2, + col="formation", + yticks=True, + xticks=False, + ), + dict( + ylabel=capacity_label, + title="", + xlim=xlim_formation, + ylim=y_range, + row=1, + col="formation", + yticks=True, + xticks=False, + ), + dict( + ylabel=ce_label, + title="", + xlim=xlim_formation, + ylim=eff_lim, + row=0, + col="formation", + yticks=True, + xticks=False, + ), + ] + ) + + return info_dicts + + def _build_standard_info_dicts( + self, + config: Any, + number_of_rows: int, + x_range: tuple, + y_range: list, + xlim_formation: tuple, + y_label: str, + top_row_ylabel: Optional[str] = None, + ) -> list: + """Build info dicts for standard plots. + + ``top_row_ylabel`` (when given) overrides the y-axis label on row + 0 only; remaining rows keep ``y_label``. Used by ``*_with_rate`` + y-sets so the rate row shows "C-rate (1/h)" instead of the + capacity label. + """ + info_dicts = [] + is_multi_row = number_of_rows > 1 + + if is_multi_row: + last_row = number_of_rows - 1 + for i in range(number_of_rows): + row_label = ( + top_row_ylabel if (i == 0 and top_row_ylabel) else y_label + ) + row_ylim = None if (i == 0 and top_row_ylabel) else y_range + xticks = None if i == last_row else False + info_dicts.append( + dict( + ylabel="" if config.show_formation else row_label, + title="", + xlim=x_range, + ylim=row_ylim, + row=i, + col="standard" if config.show_formation else None, + yticks=False if config.show_formation else None, + xticks=xticks, + ) + ) + if config.show_formation: + info_dicts.append( + dict( + ylabel=row_label, + title="", + xlim=xlim_formation, + ylim=row_ylim, + row=i, + col="formation", + yticks=None, + xticks=xticks, + ) + ) + else: + _r = 1 if config.split else None + _d = dict( + ylabel=y_label, + title="", + xlim=x_range, + ylim=y_range, + row=_r, + col=None, + yticks=None, + xticks=None, + ) + if config.show_formation: + _d["col"] = "standard" + _d["yticks"] = False + _d["ylabel"] = "" + info_dicts.append( + dict( + ylabel=y_label, + title="", + xlim=xlim_formation, + ylim=y_range, + row=_r, + col="formation", + yticks=None, + xticks=None, + ) + ) + info_dicts.append(_d) + + return info_dicts + + def _valid_number_or_none(self, x: float) -> Optional[float]: + """Clean up a number (convert NaN and Inf to None)""" + import numbers + + if isinstance(x, numbers.Number): + if not (np.isnan(x) or np.isinf(x)): + return x + return None + + def _to_numbers_or_nones(self, x: list) -> list: + """Clean up a list of numbers (convert NaN and Inf to None)""" + return [self._valid_number_or_none(i) for i in x] + + def _clean_up_axis(self, fig, info_dicts=None, row_id="row", col_id="cycle_type"): + """Clean up and configure axes based on info_dicts.""" + if info_dicts is None: + return + + # Create a dictionary with keys the same as the axis titles + info_dict = {} + for info in info_dicts: + if col_id is not None: + if row_id is not None: + info_text = f"{row_id} = {info['row']} | {col_id} = {info['col']}" + else: + info_text = f"{col_id} = {info['col']}" + else: + if row_id is not None: + info_text = f"{row_id} = {info['row']}" + else: + info_text = "single axis" + info_dict[info_text] = info + + # Iterate over the axes and set the properties + for a in fig.get_axes(): + title_text = a.get_title() + if row_id is None and col_id is None: + axis_info = info_dict.get("single axis", None) + else: + axis_info = info_dict.get(title_text, None) + if axis_info is None: + continue + + if xlim := axis_info.get("xlim", None): + a.set_xlim(self._to_numbers_or_nones(xlim)) + if ylim := axis_info.get("ylim", None): + a.set_ylim(self._to_numbers_or_nones(ylim)) + + if ylabel := axis_info.get("ylabel", None): + a.set_ylabel(ylabel) + a.set_title(axis_info.get("title", "")) + xticks = axis_info.get("xticks", False) + yticks = axis_info.get("yticks", False) + + if xticks is False: + a.set_xticks([]) + if yticks is False: + a.set_yticks([]) + + def _convert_legend_labels(self, sns_fig): + """Convert legend labels to nicer format.""" + legend = sns_fig.legend + if legend is not None: + for le in legend.get_texts(): + name = le.get_text() + name = name.replace("_", " ").title() + name = name.replace("Gravimetric", "Grav.") + name = name.replace("Cv", "(CV)") + name = name.replace("Non (CV)", "(without CV)") + le.set_text(name) + sns_fig.legend.set_title(None) + + diff --git a/cellpy/utils/plotutils.py b/cellpy/utils/plotutils.py index 26bbed78..3776ed9e 100644 --- a/cellpy/utils/plotutils.py +++ b/cellpy/utils/plotutils.py @@ -687,7 +687,8 @@ class SummaryPlotConfig: split: bool = True hover_columns: Optional[list] = None auto_convert_legend_labels: bool = True - interactive: bool = True + backend: Optional[str] = None + interactive: Optional[bool] = None share_y: bool = False rangeslider: bool = False @@ -999,860 +1000,6 @@ def _create_col_info(self, c: Any) -> tuple[tuple, dict, dict, dict]: self.y_trans = y_transformations -class SeabornPlotBuilder: - """Handles Seaborn-specific plotting logic for summary plots. - - Seaborn rendering for summary_plot (extracted from the pre-2.0 monolith) - to improve maintainability and testability. - """ - - def __init__(self): - self.y_header = "value" - self.color = "variable" - self.row = "row" - self.col_id = "cycle_type" - - def build_plot( - self, - data: pd.DataFrame, - prepared_data_info: dict, - config: SummaryPlotConfig, - additional_kwargs: dict, - c: Any, - ) -> Any: - """Build Seaborn/Matplotlib figure from prepared data. - - Args: - data: Prepared DataFrame from ``cellpy.plotting.prepare.summary`` - prepared_data_info: Dictionary with metadata from prepare - config: SummaryPlotConfig with all parameters - additional_kwargs: Additional kwargs for seaborn (from legacy function) - c: cellpy object (needed for some label generation) - - Returns: - Matplotlib figure object - """ - if not seaborn_available: - warnings.warn( - "seaborn not available, returning only the data so that you can plot it yourself instead" - ) - return data - - import seaborn as sns - import matplotlib.pyplot as plt - - # Extract seaborn-specific parameters - seaborn_facecolor = additional_kwargs.pop("seaborn_facecolor", "#EAEAF2") - seaborn_edgecolor = additional_kwargs.pop("seaborn_edgecolor", "black") - seaborn_style_dict_default = { - "axes.facecolor": seaborn_facecolor, - "axes.edgecolor": seaborn_edgecolor, - } - seaborn_style_dict = additional_kwargs.pop( - "seaborn_style_dict", seaborn_style_dict_default - ) - seaborn_marker_size = additional_kwargs.pop("seaborn_marker_size", 7) - xlim_formation = additional_kwargs.pop( - "xlim_formation", (0.6, config.formation_cycles + 0.4) - ) - - # Set default title if not provided - title = config.title - if title is None: - title = f"Summary {c.cell_name}" - - x = config.x if config.x is not None else c.schema.summary.cycle_num - y = config.y - number_of_rows = prepared_data_info["number_of_rows"] - x_label = prepared_data_info["x_label"] - y_label = prepared_data_info["y_label"] - max_cycle = prepared_data_info["max_cycle"] - max_val_normalized_col = prepared_data_info["max_val_normalized_col"] - - # Set up seaborn - sns.set_style(config.seaborn_style, seaborn_style_dict) - sns.set_palette(config.seaborn_palette) - sns.set_context(additional_kwargs.pop("seaborn_context", "notebook")) - - # Configure facet and gridspec kwargs - facet_kws = dict(despine=False, sharex=False, sharey=False) - gridspec_kws = dict(hspace=0.07) - - # Configure columns for formation cycles - col_id = None - if config.show_formation and self.col_id in data.columns: - additional_kwargs["col"] = self.col_id - number_of_cols = 2 - col_id = self.col_id - gridspec_kws["width_ratios"] = additional_kwargs.pop("width_ratios", [1, 6]) - gridspec_kws["wspace"] = additional_kwargs.pop("wspace", 0.02) - else: - number_of_cols = 1 - - # Configure rows - # Note: number_of_rows from prepared_data_info is the expected number, - # but we need to verify it matches the actual data - row_id = None - if not config.split: - number_of_rows = 1 - logging.debug(f"split=False, setting number_of_rows=1") - else: - row_id = self.row - if self.row in data.columns: - additional_kwargs["row"] = self.row - actual_number_of_rows = data[self.row].nunique() - # Use the actual number from data, but log if it differs from expected - if actual_number_of_rows != number_of_rows: - logging.warning( - f"Number of rows mismatch: expected {number_of_rows} from data preparer, " - f"but data has {actual_number_of_rows} unique row values. Using {actual_number_of_rows}." - ) - number_of_rows = actual_number_of_rows - logging.debug( - f"split=True, row column '{self.row}' found, number_of_rows={number_of_rows}" - ) - else: - # If split=True but row column doesn't exist, fall back to 1 row - logging.warning( - f"split=True but row column '{self.row}' not found in data. " - f"Expected {number_of_rows} rows but falling back to 1 row." - ) - number_of_rows = 1 - logging.debug( - f"split=True but row column '{self.row}' not found, setting number_of_rows=1" - ) - - # Calculate plot properties - plot_type = ( - "fullcell_standard" if y.startswith("fullcell_standard_") else "default" - ) - seaborn_plot_height, seaborn_plot_aspect = ( - self._calculate_seaborn_plot_properties( - number_of_rows, number_of_cols, plot_type - ) - ) - seaborn_plot_height = additional_kwargs.pop( - "seaborn_plot_height", seaborn_plot_height - ) - seaborn_plot_aspect = additional_kwargs.pop( - "seaborn_plot_aspect", seaborn_plot_aspect - ) - - # Calculate axis limits - eff_lim = config.ce_range - if eff_lim is None: - eff_lim = self._calculate_efficiency_limits(data) - - x_range = config.x_range - if x_range is None: - cycle_range = max_cycle - config.formation_cycles - if cycle_range <= 0: - cycle_range = 10 # arbitrary value - x_range = ( - config.formation_cycles + 1 - 0.02 * abs(cycle_range), - max_cycle + 0.02 * abs(cycle_range), - ) - - y_range = config.y_range - if y_range is None: - y_range = self._calculate_y_range(data) - - # Build info_dicts for axis configuration - info_dicts = self._build_axis_info_dicts( - y, - config, - number_of_rows, - x_range, - y_range, - eff_lim, - xlim_formation, - x_label, - y_label, - max_val_normalized_col, - c, - ) - - # Configure facet_kws based on plot type. ``_efficiency`` and - # ``_with_rate`` share the same row-0-is-different layout: - # disable shared y-axis and give the top row a smaller height. - is_efficiency_plot = y.endswith("_efficiency") - is_special_top_row = _has_special_top_row(y) - if is_special_top_row: - facet_kws["sharey"] = False - if number_of_rows == 2: - gridspec_kws["height_ratios"] = [1, 4] - else: - logging.debug( - f"Special-top-row plot with {number_of_rows} rows - not setting height_ratios" - ) - - facet_kws["gridspec_kws"] = gridspec_kws - - # Log configuration for debugging - logging.debug("Seaborn plot configuration:") - logging.debug( - f" y={y}, split={config.split}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" - ) - logging.debug(f" row_id={row_id}, col_id={col_id}") - logging.debug(f" is_efficiency_plot={is_efficiency_plot}") - logging.debug(f" gridspec_kws={gridspec_kws}") - logging.debug(f" additional_kwargs keys: {list(additional_kwargs.keys())}") - if config.verbose: - logging.info("Seaborn plot configuration:") - logging.info( - f" y={y}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" - ) - logging.info(f" row_id={row_id}, col_id={col_id}") - logging.info(f" is_efficiency_plot={is_efficiency_plot}") - logging.info(f" gridspec_kws={gridspec_kws}") - logging.info(f" additional_kwargs keys: {list(additional_kwargs.keys())}") - - # Create the plot - # Suppress tight_layout warning from seaborn when using gridspec_kws - # (seaborn calls tight_layout internally on axes that may be incompatible) - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=".*tight_layout.*", - category=UserWarning, - module="seaborn.axisgrid", - ) - sns_fig = sns.relplot( - data=data, - x=x, - y=self.y_header, - hue=self.color, - height=seaborn_plot_height, - aspect=seaborn_plot_aspect, - kind="line", - marker="o" if config.markers else None, - legend=config.show_legend, - **additional_kwargs, - facet_kws=facet_kws, - ) - - sns_fig.set_axis_labels(x_label, y_label) - - # Convert legend labels if requested - if config.auto_convert_legend_labels and config.show_legend: - self._convert_legend_labels(sns_fig) - - # Set marker sizes - if config.markers: - for ax in sns_fig.axes.flat: - lines = ax.get_lines() - for line in lines: - line.set_markersize(seaborn_marker_size) - - # Apply line hooks if provided - if config.seaborn_line_hooks: - for ax in sns_fig.axes.flat: - lines = ax.get_lines() - for line in lines: - for hook, args, hook_kwargs in config.seaborn_line_hooks: - if hasattr(line, hook): - getattr(line, hook)(*args, **hook_kwargs) - - # Clean up axes and set title - fig = sns_fig.figure - self._clean_up_axis(fig, info_dicts=info_dicts, row_id=row_id, col_id=col_id) - fig.align_ylabels() - _hack_to_position_legend = {1: 0.97, 2: 0.95, 3: 0.92, 4: 0.92, 5: 0.92} - fig.suptitle(title, y=_hack_to_position_legend.get(number_of_rows, 0.92)) - - plt.close(fig) - return fig - - def _calculate_seaborn_plot_properties( - self, number_of_rows: int, number_of_cols: int, plot_type: str = "default" - ) -> tuple: - """Calculate seaborn plot height and aspect ratio.""" - if plot_type == "fullcell_standard": - _selector = { - (4, 1): (2.0, 4.0), - (4, 2): (2.0, 2.0), - } - else: - _selector = { - (1, 1): (4.0, 2.05), - (1, 2): (4.0, 1.0), - (2, 1): (2.8, 2.8), - (2, 2): (2.8, 1.4), - (3, 1): (3.0, 2.7), - (3, 2): (3.0, 1.35), - (4, 1): (3.0, 2.7), - (4, 2): (3.0, 1.35), - } - return _selector.get((number_of_rows, number_of_cols), (4.0, 1.8)) - - def _calculate_efficiency_limits(self, data: pd.DataFrame) -> list: - """Calculate efficiency axis limits from data.""" - eff_vals = ( - data.loc[data[self.color].str.contains("_efficiency"), self.y_header] - .pipe(pd.to_numeric, errors="coerce") - .dropna() - ) - if len(eff_vals) == 0: - return [0, 100] - eff_min, eff_max = eff_vals.min(), eff_vals.max() - return [eff_min - 0.05 * abs(eff_min), eff_max + 0.05 * abs(eff_max)] - - def _calculate_y_range(self, data: pd.DataFrame) -> list: - """Calculate y-axis range from data.""" - y_vals = ( - data.loc[~data[self.color].str.contains("_efficiency"), self.y_header] - .pipe(pd.to_numeric, errors="coerce") - .dropna() - ) - if len(y_vals) == 0: - return [0, 1] - min_value, max_value = y_vals.min(), y_vals.max() - return [ - min_value - 0.05 * abs(min_value), - max_value + 0.05 * abs(max_value), - ] - - def _build_axis_info_dicts( - self, - y: str, - config: SummaryPlotConfig, - number_of_rows: int, - x_range: tuple, - y_range: list, - eff_lim: Optional[list], - xlim_formation: tuple, - x_label: str, - y_label: str, - max_val_normalized_col: float, - c: Any, - ) -> list: - """Build info dictionaries for axis configuration.""" - info_dicts = [] - is_efficiency_plot = y.endswith("_efficiency") - is_fullcell_standard_plot = y.startswith("fullcell_standard_") - is_split_constant_voltage_plot = y.endswith("_split_constant_voltage") - - _efficiency_label = r"Efficiency (%)" - - if is_efficiency_plot: - info_dicts.extend( - self._build_efficiency_plot_info_dicts( - config, x_range, y_range, eff_lim, xlim_formation, _efficiency_label - ) - ) - elif is_split_constant_voltage_plot: - info_dicts.extend( - self._build_cv_split_info_dicts( - config, - number_of_rows, - x_range, - y_range, - config.cv_share_range, - xlim_formation, - y_label, - ) - ) - elif is_fullcell_standard_plot: - info_dicts.extend( - self._build_fullcell_standard_info_dicts( - config, - y, - x_range, - y_range, - eff_lim, - config.cv_share_range, - config.norm_range, - max_val_normalized_col, - xlim_formation, - c, - ) - ) - else: - info_dicts.extend( - self._build_standard_info_dicts( - config, - number_of_rows, - x_range, - y_range, - xlim_formation, - y_label, - top_row_ylabel=_seaborn_top_row_label(y), - ) - ) - - return info_dicts - - def _build_efficiency_plot_info_dicts( - self, - config: SummaryPlotConfig, - x_range: tuple, - y_range: list, - eff_lim: Optional[list], - xlim_formation: tuple, - efficiency_label: str, - ) -> list: - """Build info dicts for efficiency plots.""" - info_dicts = [] - if config.show_formation: - info_dicts.extend( - [ - dict( - ylabel=efficiency_label, - title="", - xlim=xlim_formation, - ylim=eff_lim, - row=0, - col="formation", - yticks=None, - xticks=False, - ), - dict( - ylabel="", - title="", - xlim=x_range, - ylim=eff_lim, - row=0, - col="standard", - yticks=False, - xticks=False, - ), - dict( - ylabel="", - title="", - xlim=xlim_formation, - ylim=y_range, - row=1, - col="formation", - yticks=None, - xticks=None, - ), - dict( - ylabel="", - title="", - xlim=x_range, - ylim=y_range, - row=1, - col="standard", - yticks=False, - xticks=None, - ), - ] - ) - else: - info_dicts.extend( - [ - dict( - ylabel=efficiency_label, - title="", - xlim=x_range, - ylim=eff_lim, - row=0, - col=None, - yticks=None, - xticks=False, - ), - dict( - ylabel="", - title="", - xlim=x_range, - ylim=y_range, - row=1, - col=None, - yticks=None, - xticks=None, - ), - ] - ) - return info_dicts - - def _build_cv_split_info_dicts( - self, - config: SummaryPlotConfig, - number_of_rows: int, - x_range: tuple, - y_range: list, - cv_share_range: Optional[list], - xlim_formation: tuple, - y_label: str, - ) -> list: - """Build info dicts for CV split plots.""" - info_dicts = [] - - # Row names for CV split plots when split=True - row_names = ["all", "without CV", "with CV"] - - # If split=False, we only have one row - if number_of_rows == 1: - _d = dict( - ylabel=y_label, - title="", - xlim=x_range, - ylim=cv_share_range or y_range, - row=None, - col=None, - yticks=None, - xticks=None, - ) - if config.show_formation: - _d["col"] = "standard" - _d["yticks"] = False - _d["ylabel"] = "" - info_dicts.append( - dict( - ylabel=y_label, - title="", - xlim=xlim_formation, - ylim=cv_share_range or y_range, - row=None, - col="formation", - yticks=None, - xticks=None, - ) - ) - info_dicts.append(_d) - else: - # Handle 3-row case (all, without CV, with CV) - for row_name in row_names[:number_of_rows]: - if config.show_formation: - # Standard column (second column) - no y-axis labels - info_dicts.append( - dict( - ylabel="", - title="", - xlim=x_range, - ylim=cv_share_range or y_range, - row=row_name, - col="standard", - yticks=False, - xticks=True if row_name == row_names[-1] else False, - ) - ) - # Formation column (first column) - with y-axis labels - info_dicts.append( - dict( - ylabel=y_label, - title="", - xlim=xlim_formation, - ylim=cv_share_range or y_range, - row=row_name, - col="formation", - yticks=True, - xticks=True if row_name == row_names[-1] else False, - ) - ) - else: - # No formation column, single column plot - info_dicts.append( - dict( - ylabel=y_label if row_name == row_names[0] else "", - title="", - xlim=x_range, - ylim=cv_share_range or y_range, - row=row_name, - col=None, - yticks=True if row_name == row_names[0] else None, - xticks=True if row_name == row_names[-1] else False, - ) - ) - - return info_dicts - - def _build_fullcell_standard_info_dicts( - self, - config: SummaryPlotConfig, - y: str, - x_range: tuple, - y_range: list, - eff_lim: Optional[list], - cv_share_range: Optional[list], - norm_range: Optional[list], - max_val_normalized_col: float, - xlim_formation: tuple, - c: Any, - ) -> list: - """Build info dicts for fullcell standard plots.""" - info_dicts = [] - capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1]) - ce_label = "Coulombic\nEfficiency (%)" - capacity_label = f"Capacity\n({capacity_unit})" - - loss_label = f"Capacity\nRetention\n({capacity_unit})" - if ( - config.fullcell_standard_normalization_type - and config.fullcell_standard_normalization_factor is not None - ): - _norm_label = f"[{config.fullcell_standard_normalization_scaler:.1f}/{config.fullcell_standard_normalization_factor:.1f} {capacity_unit}]" - loss_label = f"Capacity\nRetention (norm.)\n{_norm_label}" - else: - loss_label = f"Capacity\nRetention\n({capacity_unit})" - - cv_label = f"CV Capacity\n({capacity_unit})" - - if config.fullcell_standard_normalization_type is not False: - cum_loss_info_range = norm_range or [ - 0.0, - max( - max_val_normalized_col, - config.fullcell_standard_normalization_scaler, - ), - ] - else: - cum_loss_info_range = norm_range or y_range - - cv_info = dict( - title="", - xlim=x_range, - ylim=cv_share_range or y_range, - row=3, - col="standard", - yticks=False, - xticks=True, - ) - cum_loss_info = dict( - title="", - xlim=x_range, - ylim=cum_loss_info_range, - row=2, - col="standard", - yticks=False, - xticks=False, - ) - capacity_info = dict( - title="", - xlim=x_range, - ylim=y_range, - row=1, - col="standard", - yticks=False, - xticks=False, - ) - ce_info = dict( - title="", - xlim=x_range, - ylim=eff_lim, - row=0, - col="standard", - yticks=False, - xticks=False, - ) - - if not config.show_formation: - cv_info["ylabel"] = cv_label - cum_loss_info["ylabel"] = loss_label - capacity_info["ylabel"] = capacity_label - ce_info["ylabel"] = ce_label - cv_info["yticks"] = True - cum_loss_info["yticks"] = True - capacity_info["yticks"] = True - ce_info["yticks"] = True - - info_dicts.extend([cv_info, cum_loss_info, capacity_info, ce_info]) - - if config.show_formation: - info_dicts.extend( - [ - dict( - ylabel=cv_label, - title="", - xlim=xlim_formation, - ylim=cv_share_range or y_range, - row=3, - col="formation", - yticks=True, - xticks=True, - ), - dict( - ylabel=loss_label, - title="", - xlim=xlim_formation, - ylim=cum_loss_info_range, - row=2, - col="formation", - yticks=True, - xticks=False, - ), - dict( - ylabel=capacity_label, - title="", - xlim=xlim_formation, - ylim=y_range, - row=1, - col="formation", - yticks=True, - xticks=False, - ), - dict( - ylabel=ce_label, - title="", - xlim=xlim_formation, - ylim=eff_lim, - row=0, - col="formation", - yticks=True, - xticks=False, - ), - ] - ) - - return info_dicts - - def _build_standard_info_dicts( - self, - config: SummaryPlotConfig, - number_of_rows: int, - x_range: tuple, - y_range: list, - xlim_formation: tuple, - y_label: str, - top_row_ylabel: Optional[str] = None, - ) -> list: - """Build info dicts for standard plots. - - ``top_row_ylabel`` (when given) overrides the y-axis label on row - 0 only; remaining rows keep ``y_label``. Used by ``*_with_rate`` - y-sets so the rate row shows "C-rate (1/h)" instead of the - capacity label. - """ - info_dicts = [] - is_multi_row = number_of_rows > 1 - - if is_multi_row: - last_row = number_of_rows - 1 - for i in range(number_of_rows): - row_label = ( - top_row_ylabel if (i == 0 and top_row_ylabel) else y_label - ) - row_ylim = None if (i == 0 and top_row_ylabel) else y_range - xticks = None if i == last_row else False - info_dicts.append( - dict( - ylabel="" if config.show_formation else row_label, - title="", - xlim=x_range, - ylim=row_ylim, - row=i, - col="standard" if config.show_formation else None, - yticks=False if config.show_formation else None, - xticks=xticks, - ) - ) - if config.show_formation: - info_dicts.append( - dict( - ylabel=row_label, - title="", - xlim=xlim_formation, - ylim=row_ylim, - row=i, - col="formation", - yticks=None, - xticks=xticks, - ) - ) - else: - _r = 1 if config.split else None - _d = dict( - ylabel=y_label, - title="", - xlim=x_range, - ylim=y_range, - row=_r, - col=None, - yticks=None, - xticks=None, - ) - if config.show_formation: - _d["col"] = "standard" - _d["yticks"] = False - _d["ylabel"] = "" - info_dicts.append( - dict( - ylabel=y_label, - title="", - xlim=xlim_formation, - ylim=y_range, - row=_r, - col="formation", - yticks=None, - xticks=None, - ) - ) - info_dicts.append(_d) - - return info_dicts - - def _valid_number_or_none(self, x: float) -> Optional[float]: - """Clean up a number (convert NaN and Inf to None)""" - import numbers - - if isinstance(x, numbers.Number): - if not (np.isnan(x) or np.isinf(x)): - return x - return None - - def _to_numbers_or_nones(self, x: list) -> list: - """Clean up a list of numbers (convert NaN and Inf to None)""" - return [self._valid_number_or_none(i) for i in x] - - def _clean_up_axis(self, fig, info_dicts=None, row_id="row", col_id="cycle_type"): - """Clean up and configure axes based on info_dicts.""" - if info_dicts is None: - return - - # Create a dictionary with keys the same as the axis titles - info_dict = {} - for info in info_dicts: - if col_id is not None: - if row_id is not None: - info_text = f"{row_id} = {info['row']} | {col_id} = {info['col']}" - else: - info_text = f"{col_id} = {info['col']}" - else: - if row_id is not None: - info_text = f"{row_id} = {info['row']}" - else: - info_text = "single axis" - info_dict[info_text] = info - - # Iterate over the axes and set the properties - for a in fig.get_axes(): - title_text = a.get_title() - if row_id is None and col_id is None: - axis_info = info_dict.get("single axis", None) - else: - axis_info = info_dict.get(title_text, None) - if axis_info is None: - continue - - if xlim := axis_info.get("xlim", None): - a.set_xlim(self._to_numbers_or_nones(xlim)) - if ylim := axis_info.get("ylim", None): - a.set_ylim(self._to_numbers_or_nones(ylim)) - - if ylabel := axis_info.get("ylabel", None): - a.set_ylabel(ylabel) - a.set_title(axis_info.get("title", "")) - xticks = axis_info.get("xticks", False) - yticks = axis_info.get("yticks", False) - - if xticks is False: - a.set_xticks([]) - if yticks is False: - a.set_yticks([]) - - def _convert_legend_labels(self, sns_fig): - """Convert legend labels to nicer format.""" - legend = sns_fig.legend - if legend is not None: - for le in legend.get_texts(): - name = le.get_text() - name = name.replace("_", " ").title() - name = name.replace("Gravimetric", "Grav.") - name = name.replace("Cv", "(CV)") - name = name.replace("Non (CV)", "(without CV)") - le.set_text(name) - sns_fig.legend.set_title(None) - def summary_plot_legacy( c, @@ -1962,7 +1109,8 @@ def summary_plot( split: bool = True, hover_columns: Optional[list] = None, auto_convert_legend_labels: bool = True, - interactive: bool = True, + backend: Optional[str] = None, + interactive: Optional[bool] = None, share_y: bool = False, rangeslider: bool = False, return_data: bool = False, @@ -2012,14 +1160,17 @@ def summary_plot( split: split the plot hover_columns: columns to show in the hover tooltip (only for plotly) auto_convert_legend_labels: convert the legend labels to a nicer format. - interactive: use interactive plotting (plotly) + backend: plotting backend (``"plotly"`` or ``"matplotlib"``; default + ``"plotly"``) + interactive: deprecated alias for backend selection + (``True`` → ``"plotly"``, ``False`` → ``"matplotlib"``); removal 2.1 rangeslider: add a range slider to the x-axis (only for plotly) share_y: share y-axis (only for plotly) return_data: return the data used for plotting verbose: print out some extra information to make it easier to find out what to plot next time plotly_template: name of the plotly template to use - seaborn_palette: name of the seaborn palette to use - seaborn_style: name of the seaborn style to use + seaborn_palette: name of the seaborn palette to use (matplotlib backend) + seaborn_style: name of the seaborn style to use (matplotlib backend) formation_cycles: number of formation cycles to show show_formation: show formation cycles show_legend: show the legend @@ -2052,8 +1203,8 @@ def summary_plot( Returns: if ``return_data`` is True, returns a tuple with the figure and the data used for plotting. - Otherwise, it returns only the figure. If ``interactive`` is True, the figure is a ``plotly`` figure, - else it is a ``matplotlib`` figure. + Otherwise, it returns only the figure. With ``backend="plotly"`` the figure is a + plotly figure; with ``backend="matplotlib"`` it is a matplotlib figure. Examples: Default plot (capacity and Coulombic efficiency vs cycle number):: @@ -2066,10 +1217,10 @@ def summary_plot( >>> fig = summary_plot(c, y="capacities_gravimetric", show_formation=False) - Use the non-interactive (matplotlib/seaborn) backend, e.g. for an + Use the matplotlib backend (seaborn styling), e.g. for an SVG export from a script:: - >>> fig = summary_plot(c, y="capacities_gravimetric", interactive=False) + >>> fig = summary_plot(c, y="capacities_gravimetric", backend="matplotlib") >>> fig.savefig("summary.svg") Get the prepared DataFrame back together with the figure (useful @@ -2148,6 +1299,7 @@ def summary_plot( split=split, hover_columns=hover_columns, auto_convert_legend_labels=auto_convert_legend_labels, + backend=backend, interactive=interactive, share_y=share_y, rangeslider=rangeslider, @@ -2179,16 +1331,28 @@ def summary_plot( config.x = _resolve_summary_column(c, config.x) config.hover_columns = _resolve_summary_columns(c, config.hover_columns) - # Check if interactive mode is requested and plotly is available - if config.interactive: - if not plotly_available: - warnings.warn( - "plotly not available, and it is currently the only supported interactive backend" - ) - return None + # Resolve backend= vs deprecated interactive= (#639). + resolved_backend = config.backend + if config.interactive is not None: + warn_once( + "summary_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", + ) + if resolved_backend is None: + resolved_backend = "plotly" if config.interactive else "matplotlib" + if resolved_backend is None: + resolved_backend = "plotly" + config.backend = resolved_backend + + if resolved_backend == "plotly" and not plotly_available: + warnings.warn( + "plotly not available, and it is currently the only supported plotly backend" + ) + return None - # prepare → spec → render (#638) - from cellpy.plotting.backends.plotly import PlotlyBackend + # prepare → spec → render (#638 / #639) + from cellpy.plotting.backends import get_backend from cellpy.plotting.context import from_source from cellpy.plotting.prepare.summary import prepare as prepare_summary @@ -2197,21 +1361,11 @@ def summary_plot( ctx = from_source(c) frame, spec = prepare_summary(ctx, family, config, plot_info=plot_info) - if config.interactive: - fig = PlotlyBackend().render(frame, spec) - else: - prepared_data_info = dict(spec.extras.get("prepared_data_info") or {}) - # Seaborn builder still expects the frame under the old info dict shape. - prepared_data_info.setdefault("number_of_rows", spec.extras.get("number_of_rows")) - prepared_data_info.setdefault("x_label", spec.x_axis.label) - prepared_data_info.setdefault("y_label", spec.extras.get("y_label")) - fig = SeabornPlotBuilder().build_plot( - frame, - prepared_data_info, - config, - dict(config.additional_kwargs), - c, - ) + # MatplotlibBackend still needs the live config/cell for seaborn styling knobs. + spec.extras["config"] = config + spec.extras["cell"] = c + + fig = get_backend(resolved_backend).render(frame, spec) if config.return_data: return fig, frame diff --git a/tests/figure_spec_support.py b/tests/figure_spec_support.py index 0dcdcd06..b49f4b92 100644 --- a/tests/figure_spec_support.py +++ b/tests/figure_spec_support.py @@ -173,7 +173,7 @@ def _summary_cases() -> list[FigureCase]: FigureCase( name=f"summary_plot[{family}][plotly]", function="summary_plot", - kwargs={"y": family, "interactive": True}, + kwargs={"y": family, "backend": "plotly"}, needs_plotly=True, ) ) @@ -181,7 +181,7 @@ def _summary_cases() -> list[FigureCase]: FigureCase( name=f"summary_plot[{family}][matplotlib]", function="summary_plot", - kwargs={"y": family, "interactive": False}, + kwargs={"y": family, "backend": "matplotlib"}, needs_seaborn=True, ) ) @@ -207,7 +207,7 @@ def _summary_option_cases() -> list[FigureCase]: FigureCase( name=f"summary_plot[{label}][plotly]", function="summary_plot", - kwargs={"y": family, "interactive": True, **kwargs}, + kwargs={"y": family, "backend": "plotly", **kwargs}, needs_plotly=True, ) ) diff --git a/tests/test_mpl_backend.py b/tests/test_mpl_backend.py new file mode 100644 index 00000000..a38c3b73 --- /dev/null +++ b/tests/test_mpl_backend.py @@ -0,0 +1,79 @@ +"""Tests for the matplotlib summary backend and backend= API (#639).""" + +from __future__ import annotations + +import warnings + +import pytest + +from cellpy.plotting.backends import get_backend +from cellpy.plotting.backends.base import Backend +from cellpy.plotting.backends.mpl import MatplotlibBackend +from cellpy.plotting.backends.plotly import PlotlyBackend +from cellpy.utils import plotutils +from cellpy.utils.plotutils import summary_plot + + +@pytest.mark.essential +def test_get_backend_known_names(): + assert isinstance(get_backend("plotly"), PlotlyBackend) + assert isinstance(get_backend("matplotlib"), MatplotlibBackend) + assert isinstance(get_backend("plotly"), Backend) + assert isinstance(get_backend("matplotlib"), Backend) + + +@pytest.mark.essential +def test_get_backend_unknown_raises(): + with pytest.raises(ValueError, match="unknown plotting backend"): + get_backend("seaborn") + + +@pytest.mark.essential +def test_seaborn_plot_builder_is_gone(): + assert not hasattr(plotutils, "SeabornPlotBuilder") + + +@pytest.mark.essential +def test_interactive_alias_warns_and_maps(cell): + from cellpy import _deprecation + + # warn_once is once-per-call-site across the whole process; reset so this + # test is order-independent in the essential suite. + _deprecation._WARNED_SITES.clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + fig = summary_plot( + cell, + y="capacities_gravimetric", + interactive=False, + show_formation=False, + ) + assert fig is not None + messages = [ + str(w.message) + for w in caught + if issubclass(w.category, DeprecationWarning) + and "interactive" in str(w.message) + ] + assert messages, "expected DeprecationWarning for interactive=" + assert "backend=" in messages[0] or "matplotlib" in messages[0] + + +@pytest.mark.essential +def test_backend_matplotlib_no_interactive_warning(cell): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + fig = summary_plot( + cell, + y="capacities_gravimetric", + backend="matplotlib", + show_formation=False, + ) + assert fig is not None + interactive_warns = [ + w + for w in caught + if issubclass(w.category, DeprecationWarning) + and "interactive" in str(w.message) + ] + assert not interactive_warns diff --git a/tests/test_plotutils_summary_plot.py b/tests/test_plotutils_summary_plot.py index 0787854e..2833703c 100644 --- a/tests/test_plotutils_summary_plot.py +++ b/tests/test_plotutils_summary_plot.py @@ -46,7 +46,7 @@ class TestSummaryPlotBasic: ) def test_basic_plot_types(self, cell, y_param): """Test that all basic plot types can be created.""" - fig = summary_plot(cell, y=y_param, interactive=False, show_formation=False) + fig = summary_plot(cell, y=y_param, backend="matplotlib", show_formation=False) assert fig is not None # For seaborn, should return matplotlib figure assert hasattr(fig, "get_axes") or hasattr(fig, "show") @@ -60,7 +60,7 @@ def test_basic_plot_types(self, cell, y_param): ) def test_cv_split_plot_types(self, cell, y_param): """Test CV split plot types.""" - fig = summary_plot(cell, y=y_param, interactive=False, show_formation=False) + fig = summary_plot(cell, y=y_param, backend="matplotlib", show_formation=False) assert fig is not None @pytest.mark.parametrize( @@ -73,7 +73,7 @@ def test_cv_split_plot_types(self, cell, y_param): ) def test_fullcell_standard_plot_types(self, cell, y_param): """Test fullcell standard plot types.""" - fig = summary_plot(cell, y=y_param, interactive=False, show_formation=False) + fig = summary_plot(cell, y=y_param, backend="matplotlib", show_formation=False) assert fig is not None @@ -89,7 +89,7 @@ def test_interactive_mode(self, cell): fig = summary_plot( cell, y="capacities_gravimetric_coulombic_efficiency", - interactive=True, + backend="plotly", show_formation=False, ) assert fig is not None @@ -101,7 +101,7 @@ def test_interactive_with_formation(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=True, + backend="plotly", show_formation=True, formation_cycles=3, ) @@ -120,7 +120,7 @@ def test_seaborn_mode(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", show_formation=False, ) assert fig is not None @@ -132,7 +132,7 @@ def test_formation_cycles_seaborn(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", show_formation=True, formation_cycles=3, ) @@ -143,7 +143,7 @@ def test_formation_cycles_disabled(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", show_formation=False, ) assert fig is not None @@ -154,7 +154,7 @@ def test_custom_x_axis(self, cell): cell, x="cycle_index", y="capacities_gravimetric", - interactive=False, + backend="matplotlib", ) assert fig is not None @@ -165,19 +165,19 @@ def test_custom_ranges(self, cell): y="capacities_gravimetric", x_range=[1, 10], y_range=[0, 200], - interactive=False, + backend="matplotlib", ) assert fig is not None def test_markers(self, cell): """Test marker parameter.""" fig = summary_plot( - cell, y="capacities_gravimetric", markers=True, interactive=False + cell, y="capacities_gravimetric", markers=True, backend="matplotlib" ) assert fig is not None fig_no_markers = summary_plot( - cell, y="capacities_gravimetric", markers=False, interactive=False + cell, y="capacities_gravimetric", markers=False, backend="matplotlib" ) assert fig_no_markers is not None @@ -187,7 +187,7 @@ def test_title(self, cell): cell, y="capacities_gravimetric", title="Test Plot", - interactive=False, + backend="matplotlib", ) assert fig is not None @@ -197,7 +197,7 @@ def test_return_data(self, cell): cell, y="capacities_gravimetric", return_data=True, - interactive=False, + backend="matplotlib", ) assert isinstance(result, tuple) assert len(result) == 2 @@ -213,7 +213,7 @@ def test_return_data_structure(self, cell): cell, y="capacities_gravimetric", return_data=True, - interactive=False, + backend="matplotlib", ) # Should have variable and value columns after melting assert "variable" in data.columns or "value" in data.columns @@ -225,7 +225,7 @@ def test_fullcell_standard_normalization(self, cell): y="fullcell_standard_gravimetric", fullcell_standard_normalization_type="divide", fullcell_standard_normalization_factor=1500.0, - interactive=False, + backend="matplotlib", ) assert fig is not None @@ -235,7 +235,7 @@ def test_fullcell_standard_reset_losses(self, cell): cell, y="fullcell_standard_gravimetric", reset_losses=True, - interactive=False, + backend="matplotlib", ) assert fig is not None @@ -255,7 +255,7 @@ def test_empty_summary(self, cellpy_data_instance): with pytest.raises(NoDataFound): print(f"This should raise NoDataFound: {len(cell.data.summary)=}") with pytest.raises(NoDataFound): - summary_plot(cell, y="capacities_gravimetric", interactive=False) + summary_plot(cell, y="capacities_gravimetric", backend="matplotlib") def test_single_cycle(self, cell): """Test with minimal data (if possible).""" @@ -264,7 +264,7 @@ def test_single_cycle(self, cell): cell, y="capacities_gravimetric", x_range=[1, 2], - interactive=False, + backend="matplotlib", ) # Should still create a figure (might be empty) assert fig is not None @@ -273,7 +273,7 @@ def test_single_cycle(self, cell): cell, y="capacities_gravimetric", x_range=[100000, 100001], - interactive=False, + backend="matplotlib", show_formation=False, ) # Should still create a figure (might be empty) @@ -285,7 +285,7 @@ def test_invalid_y_parameter(self, cell): # TODO: replace with NoDataFound # TODO: add test for other invalid parameters with pytest.raises(ValueError): - summary_plot(cell, y="invalid_plot_type", interactive=False) + summary_plot(cell, y="invalid_plot_type", backend="matplotlib") @pytest.mark.skipif( @@ -301,7 +301,7 @@ def test_golden_reference_data_structure(self, cell): cell, y="capacities_gravimetric_coulombic_efficiency", return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) @@ -319,7 +319,7 @@ def test_golden_reference_columns(self, cell): cell, y="capacities_gravimetric", return_data=True, - interactive=False, + backend="matplotlib", ) # Check for expected columns after melting @@ -332,7 +332,7 @@ def test_golden_reference_figure_properties_plotly(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=True, + backend="plotly", show_formation=False, ) @@ -346,7 +346,7 @@ def test_golden_reference_figure_properties_seaborn(self, cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", show_formation=False, ) @@ -380,7 +380,7 @@ def test_filters_rate_range_drops_rows(self, cell): summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", show_formation=False, ) with pytest.raises(ValueError, match="No data found"): @@ -388,7 +388,7 @@ def test_filters_rate_range_drops_rows(self, cell): cell, y="capacities_gravimetric", filters={"rate": (1e6, 1e7)}, - interactive=False, + backend="matplotlib", show_formation=False, ) @@ -399,7 +399,7 @@ def test_filters_passthrough_when_none(self, cell): cell, y="capacities_gravimetric", return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) _, with_none = summary_plot( @@ -407,7 +407,7 @@ def test_filters_passthrough_when_none(self, cell): y="capacities_gravimetric", filters=None, return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) assert len(with_none) == len(baseline) @@ -424,7 +424,7 @@ def test_nominal_capacity_rescales_rate_columns(self, cell): cell, y="capacities_gravimetric_with_rate", return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) _, scaled = summary_plot( @@ -432,7 +432,7 @@ def test_nominal_capacity_rescales_rate_columns(self, cell): y="capacities_gravimetric_with_rate", nominal_capacity=old_nom * 2.0, return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) @@ -453,7 +453,7 @@ def test_with_rate_yset_produces_rate_rows(self, cell): cell, y="capacities_gravimetric_with_rate", return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) variables = set(data["variable"].unique()) @@ -469,7 +469,7 @@ def test_seaborn_with_formation_clears_facet_titles(self, cell): fig = summary_plot( cell, y="capacities_gravimetric_with_rate", - interactive=False, + backend="matplotlib", show_formation=True, formation_cycles=3, ) @@ -498,7 +498,7 @@ def test_hover_columns_added(self, cell): y="capacities_gravimetric", hover_columns=extras, return_data=True, - interactive=True, + backend="plotly", show_formation=False, ) @@ -522,7 +522,7 @@ def test_hover_columns_unknown_warns(self, cell, caplog): y="capacities_gravimetric", hover_columns=[hdr.test_time, "definitely_not_a_real_column"], return_data=True, - interactive=True, + backend="plotly", show_formation=False, ) @@ -542,7 +542,7 @@ def test_hover_columns_ignored_for_fullcell(self, cell, caplog): cell, y="fullcell_standard_gravimetric", hover_columns=[hdr.test_time], - interactive=True, + backend="plotly", show_formation=False, ) @@ -598,7 +598,7 @@ def test_plotly_with_zero_or_false_formation_cycles( fig = summary_plot( cell, y="capacities_gravimetric", - interactive=True, + backend="plotly", formation_cycles=formation_cycles_arg, ) assert fig is not None @@ -616,7 +616,7 @@ def test_seaborn_with_zero_or_false_formation_cycles( fig = summary_plot( cell, y="capacities_gravimetric", - interactive=False, + backend="matplotlib", formation_cycles=formation_cycles_arg, ) assert fig is not None diff --git a/tests/test_summary_prepare.py b/tests/test_summary_prepare.py index b72a6701..36f90c92 100644 --- a/tests/test_summary_prepare.py +++ b/tests/test_summary_prepare.py @@ -48,7 +48,7 @@ def test_return_data_frame_shape(cell): cell, y="capacities_gravimetric", return_data=True, - interactive=False, + backend="matplotlib", show_formation=False, ) assert fig is not None @@ -65,7 +65,7 @@ def test_plotly_path_uses_backend(cell): fig = summary_plot( cell, y="capacities_gravimetric", - interactive=True, + backend="plotly", show_formation=True, ) assert fig is not None