diff --git a/.issueflows/03-solved-issues/issue638_original.md b/.issueflows/03-solved-issues/issue638_original.md
new file mode 100644
index 00000000..b080204e
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue638_original.md
@@ -0,0 +1,25 @@
+# Issue #638: Port summary prepare path and flip summary_plot to prepare→spec→render
+
+Source: https://github.com/jepegit/cellpy/issues/638
+
+## 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/prepare/summary.py` by extracting/reusing `SummaryPlotDataPreparer` (filters, rate rescaling, normalization, formation marking, CV partitioning → tidy long frame + `FigureSpec`). Point public `summary_plot` at context adapter → registry → prepare → backend.render. Preserve the user-facing signature and defaults (including named `y=` strings and `return_data=True` frame shape).
+
+## Acceptance
+
+- Figure-spec oracle green for all summary families × both backends currently covered.
+- `return_data` frame columns/dtypes match today's builder output on the oracle cell.
+- `SummaryPlotDataPreparer` / `PlotlyPlotBuilder` live code either becomes the prepare/backend implementation or is deleted in this PR — no third parallel summary path left.
+
+## Depends on
+
+#637
+
+Part of epic #567.
diff --git a/.issueflows/03-solved-issues/issue638_plan.md b/.issueflows/03-solved-issues/issue638_plan.md
new file mode 100644
index 00000000..fbe57b1b
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue638_plan.md
@@ -0,0 +1,104 @@
+# Issue #638 — Plan: port summary prepare path; flip `summary_plot` to prepare→spec→render
+
+## Goal
+
+Make public `summary_plot` run **context → registry → prepare → backend.render** for the interactive (plotly) path: extract prepare into `cellpy/plotting/prepare/summary.py` (emitting a tidy frame + real `FigureSpec`), absorb `PlotlyPlotBuilder` into `PlotlyBackend.render`, and leave no third parallel summary prepare/render path. Preserve signature, defaults, and `return_data` frame shape.
+
+## Constraints
+
+- Plan of record: [`architecture-plan/cellpy2-plotting-redesign-plan.md`](../../../architecture-plan/cellpy2-plotting-redesign-plan.md) §3.1 / Phase 2 items 2–3; epic [`.issueflows/05-epics/epic567_plan.md`](../05-epics/epic567_plan.md) Stage 1 issue 3.
+- Depends on #636/#637 (merged): registry + `FigureSpec` exist; formation layout lives in `backends/plotly.py`; `PlotlyBackend.render` is provisional/incomplete; public path still uses `SummaryPlotDataPreparer` + `PlotlyPlotBuilder` / `SeabornPlotBuilder`.
+- Oracle gate: `tests/test_figure_specs.py` green for all summary families × both backends currently covered (**no** intentional `figure_specs.json` regen unless a proven intentional visual change).
+- Do **not** add `backends/mpl.py` or delete `SeabornPlotBuilder` (that is #639). Do **not** port collectors / batch faceting.
+- User-facing `summary_plot` signature and defaults stay verbatim (including named `y=` and `return_data=True`).
+- Not yolo — flag-day for the main plot entry; needs careful parity.
+
+### Prior art
+
+- `SummaryPlotDataPreparer` — `cellpy/utils/plotutils.py` (~1008–1420). Filters, rate rescale, normalization, formation marking, CV / fullcell / standard melts → tidy frame + metadata dict (`data`, `number_of_rows`, labels, cycle bounds, `formation_cycle_selector`).
+- `PlotlyPlotBuilder.build_plot` — same module (~1429–1853). Owns `px.line` construction, hover/facet kwargs, formation adapter → `configure_formation_layout`, **no-formation** layout (`_configure_no_formation_axes`), legend prettify, rangeslider / share_y. This is what must land inside `PlotlyBackend.render` (or helpers it calls).
+- `SeabornPlotBuilder` — same module (~1856+). Stays until #639; must consume the **same** prepared frame from the new prepare module.
+- `PlotlyBackend.render` — `cellpy/plotting/backends/plotly.py`. Minimal `px.line` + formation via `extras`; missing no-formation, legend convert, height defaults, hover, fullcell post-steps wiring from config, etc. `CELLPY_SUMMARY_PLOTLY_SPEC` / `use_spec_render()` exist but are **not** wired into `summary_plot` today.
+- `SummaryPlotInfo` + registry — column sets / labels already registry-backed (#636); prepare keeps using them (or equivalent) rather than reinventing column tables.
+- Architecture sketch: `context.from_source` → `registry.get` → `prepare.summary.prepare` → `backends.get(backend).render` — no `context.py` yet.
+- Toolbox / graphify: nothing relevant.
+
+## Approach
+
+1. **Minimal context adapter** — add `cellpy/plotting/context.py` with a thin `CellContext` (or `from_source(c)`) that exposes what prepare/render need from a `CellpyCell` (`data.summary`, headers/schema, `make_summary`, `cell_name`, units helpers). BatchContext is out of scope. `summary_plot` builds context once and passes it into prepare (and into plotly render only where cell-bound labels still need it, e.g. capacity units / title).
+
+2. **`cellpy/plotting/prepare/summary.py`**
+ - Move `SummaryPlotDataPreparer` here ( mechanistically; keep private helpers). Public function shape aligned with the plan of record:
+
+ ```python
+ def prepare(ctx, family, config) -> tuple[pd.DataFrame, FigureSpec]:
+ ...
+ ```
+
+ - Still produce today’s tidy long frame (same columns / dtypes / row marks for formation and panel `row`).
+ - Build a **real** `FigureSpec`: panels from family/row count; `x_axis` / per-panel `y_axis.range` from config (`y_range`, `ce_range`, `norm_range`, `cv_share_range`); `supports_formation` from family + `config.show_formation`; put render knobs that are not yet first-class fields into `spec.extras` (x column name, `y_header`, plotly facet keys, formation domains/ranges once computed, fullcell domain kwargs, height/markers/template flags, etc.). Prefer computing formation domain/range numbers in prepare (or a small shared helper) so `render` stays declarative.
+ - Delete the class from `plotutils` (or leave a thin re-export alias only if something external imports it — prefer delete; nothing in tests should import the class name).
+
+3. **Complete `PlotlyBackend.render(frame, spec)`**
+ - Port the body of `PlotlyPlotBuilder.build_plot` (including **no-formation** path and legend conversion) to operate from `(frame, FigureSpec)` + whatever tiny cell-bound bits remain on context/extras.
+ - Formation continues to call `configure_formation_layout` / `configure_fullcell_standard_domains`.
+ - Delete `PlotlyPlotBuilder` from the live path (class removed or reduced to a one-liner deprecated shim that calls the backend — prefer remove).
+ - Remove the provisional `CELLPY_SUMMARY_PLOTLY_SPEC` dual-path concept (flag unused today): interactive path **always** uses `PlotlyBackend.render`. No third path.
+
+4. **Flip `summary_plot`**
+ ```text
+ config → resolve columns → CellContext
+ → registry.get(y) / SummaryPlotInfo as needed
+ → prepare.summary.prepare(ctx, family, config) → frame, spec
+ → if interactive: PlotlyBackend().render(frame, spec)
+ else: SeabornPlotBuilder().build_plot(frame, prepared_meta_or_spec, …)
+ → return_data ? (fig, frame) : fig
+ ```
+ - `interactive=False` keeps `SeabornPlotBuilder` until #639, but it must use the **same** `frame` from `prepare/summary.py` (adapt its `prepared_data_info` needs from `FigureSpec` / a small metadata side-channel if required — do not re-run preparer logic).
+ - Keep the public function in `cellpy.utils.plotutils` for this PR (permanent re-export home already decided); no API move to `cellpy.plotting.summary_plot` required here.
+
+5. **Package exports**
+ - `cellpy/plotting/prepare/__init__.py` (+ optional `summary` re-export).
+ - Light updates to `cellpy/plotting/__init__.py` / `backends` docs; update [`.issueflows/04-designs-and-guides/plotting-backends.md`](../04-designs-and-guides/plotting-backends.md) and add a short prepare note (or extend registry/backends docs) recording the flip.
+
+6. **Parity / tests**
+ - Oracle: all summary families × plotly + seaborn paths green.
+ - Add focused tests: prepare returns `(frame, FigureSpec)` with expected panel count / extras keys for 1–2 representative families; `return_data` columns match pre-flip shape on the oracle cell; asserting `PlotlyPlotBuilder` is gone (or not imported by `summary_plot`).
+ - Existing `tests/test_plotutils_summary_plot.py` stays green.
+
+## Files to touch
+
+| Path | Change |
+|---|---|
+| `cellpy/plotting/context.py` | **new** — minimal `CellContext` / `from_source` |
+| `cellpy/plotting/prepare/__init__.py` | **new** |
+| `cellpy/plotting/prepare/summary.py` | **new** — port of `SummaryPlotDataPreparer` + `FigureSpec` emission |
+| `cellpy/plotting/backends/plotly.py` | complete `render`; drop unused spec-render env gate; own no-formation layout |
+| `cellpy/plotting/backends/__init__.py` | drop/adjust `use_spec_render` exports if removed |
+| `cellpy/plotting/spec.py` | only if documented `extras` keys or small field tweaks needed |
+| `cellpy/utils/plotutils.py` | thin `summary_plot` orchestration; delete preparer + `PlotlyPlotBuilder`; keep `SeabornPlotBuilder` + helpers still shared |
+| `cellpy/plotting/__init__.py` | optional exports |
+| `tests/test_summary_prepare.py` (name flexible) | **new** — prepare + return_data / spec shape |
+| `tests/test_figure_specs.py` / `test_plotutils_summary_plot.py` / `test_plotly_backend_layout.py` | run; adjust imports if builders move |
+| `.issueflows/04-designs-and-guides/plotting-backends.md` (+ prepare note) | document the flip |
+
+## Test strategy
+
+```bash
+uv sync --extra batch
+MPLBACKEND=Agg uv run pytest tests/test_summary_prepare.py tests/test_plotly_backend_layout.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: formation on/off, CE / with_rate / CV-split / fullcell families, `return_data` columns/dtypes on the oracle cell, both `interactive=True/False`.
+
+## Open questions
+
+1. **How thin is `context.py`?** — **Recommend:** minimal `CellContext` wrapping one `CellpyCell` (attributes/methods prepare already needs). No BatchContext. Alternative: skip new module and pass `c` into prepare until collectors stage — weaker vs issue text (“context adapter”).
+2. **`SeabornPlotBuilder` this PR?** — **Recommend:** keep as the only `interactive=False` renderer, fed by the new prepare output; delete only `PlotlyPlotBuilder` + old preparer. Meets “no third path” (one prepare, plotly via backend, seaborn temporary). Alternative: also stub mpl backend early — rejected (belongs to #639).
+3. **Escape-hatch env flag?** — **Recommend:** no dual path after flip (remove `CELLPY_SUMMARY_PLOTLY_SPEC`). Oracle + existing unit tests are the safety net; a legacy flag would reintroduce the third path the issue forbids. Alternative: keep `CELLPY_LEGACY_SUMMARY_PLOTLY=1` for one release — only if Accept asks for it.
+4. **Where formation domain/range math lives** — **Recommend:** compute in prepare (or a shared helper used by prepare) and stash on `FigureSpec.extras`, so `render` applies layout without re-reading the frame for cycle bounds. Alternative: keep that math inside `PlotlyBackend` (closer to today’s builder) — acceptable if it unblocks parity faster; still one path.
+
+## Scope check
+
+Single Stage-1 flag-day PR: prepare module + context stub + complete plotly render + `summary_plot` flip + delete preparer/`PlotlyPlotBuilder`. Fits one PR. Follow-up already published: #639 (mpl backend; retire seaborn builder).
diff --git a/.issueflows/03-solved-issues/issue638_status.md b/.issueflows/03-solved-issues/issue638_status.md
new file mode 100644
index 00000000..d46f0863
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue638_status.md
@@ -0,0 +1,22 @@
+# Issue #638 — Status
+
+- [x] Done
+
+## What's done
+
+- Plan accepted (2026-07-23).
+- Branch `cursor/638-summary-prepare-render-4efd`; draft PR #644.
+- Added `cellpy/plotting/context.py` (`CellContext` / `from_source`).
+- Ported preparer to `cellpy/plotting/prepare/summary.py`; `prepare()` returns `(frame, FigureSpec)` with formation/no-formation knobs in `extras['render']`.
+- Completed `PlotlyBackend.render`; deleted `PlotlyPlotBuilder` + `CELLPY_SUMMARY_PLOTLY_SPEC` dual-path.
+- Flipped public `summary_plot` to context → registry → prepare → render; `SeabornPlotBuilder` kept on the same frame until #639.
+- Design notes: `plotting-prepare.md`; updated backends/registry docs.
+- Tests: `tests/test_summary_prepare.py`; layout test asserts builders gone.
+- Verified green on close:
+ - prepare + layout + figure-specs (67)
+ - `pytest -m essential --ignore=tests/test_arbin_variants_two_stage.py` (551)
+- 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 bbd709ba..521c501b 100644
--- a/.issueflows/04-designs-and-guides/plotting-backends.md
+++ b/.issueflows/04-designs-and-guides/plotting-backends.md
@@ -1,27 +1,29 @@
-# Plotting backends (formation layout)
+# Plotting backends (formation layout + render)
## 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 before prepare→spec→render (#638).
+one layout engine and a real prepare→spec→render path for `summary_plot`.
## Decision
- **Formation layout lives in** `cellpy/plotting/backends/plotly.py`
- (`configure_formation_layout`). `PlotlyPlotBuilder._configure_formation_axes`
- is a thin adapter that computes domains/ranges and calls it.
+ (`configure_formation_layout`).
- **Per-row-count methods are deleted.** Specials stay as parameters/helpers:
- N=1 annotation y; optional `top_row_label` domains;
+ N=1 annotation y; optional `top_row_label` domains;
`configure_fullcell_standard_domains` for fullcell 4-row titles.
-- **`Backend` protocol** in `backends/base.py`; `PlotlyBackend.render` exists
- for #638. Public `summary_plot` does **not** flip yet.
-- **Provisional full-render flag:** env `CELLPY_SUMMARY_PLOTLY_SPEC=1`
- (default off). Layout engine itself is always on.
-- **No-formation path** stays on `PlotlyPlotBuilder` until #638.
+- **`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.
## Links
-- Issues #637, #636; epic #567
+- Issues #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
new file mode 100644
index 00000000..139ea5f5
--- /dev/null
+++ b/.issueflows/04-designs-and-guides/plotting-prepare.md
@@ -0,0 +1,26 @@
+# Plotting prepare (summary)
+
+## 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.
+
+## Decision
+
+- **Prepare lives in** `cellpy/plotting/prepare/summary.py`.
+ `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.
+- **`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.
+
+## Links
+
+- Issue #638; epic #567
+- Related: `plotting-registry.md`, `plotting-backends.md`
diff --git a/.issueflows/04-designs-and-guides/plotting-registry.md b/.issueflows/04-designs-and-guides/plotting-registry.md
index cca9e69a..d2215fcb 100644
--- a/.issueflows/04-designs-and-guides/plotting-registry.md
+++ b/.issueflows/04-designs-and-guides/plotting-registry.md
@@ -14,8 +14,9 @@ moves selection into `cellpy.plotting.registry`.
- **Unknown `y` fails loudly** (`ValueError` listing known names). Undocumented
raw-column fallthrough (`y_cols.get(y, y)`) is gone; extension is
`_register_family` (provisional in 2.0).
-- **`FigureSpec` / `PanelSpec` / `AxisSpec`** land in `cellpy/plotting/spec.py`
- but are not consumed by builders until Stage-1 prepare→spec→render (#637–#639).
+- **`FigureSpec` / `PanelSpec` / `AxisSpec`** live in `cellpy/plotting/spec.py`.
+ Prepare emits a real `FigureSpec` for `summary_plot` (#638); matplotlib
+ backend consumption is #639.
- **Oracle menu** (`tests/figure_spec_support.SUMMARY_FAMILIES`) derives from
`families()` so the snapshot menu cannot drift from the runtime menu.
diff --git a/HISTORY.md b/HISTORY.md
index 4757cc05..e0aa4f80 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -2,6 +2,8 @@
## [Unreleased]
+* Port summary prepare path and flip summary_plot to prepare→spec→render (#638).
+
* Generic plotly panel/formation layout backend (#637).
* Add FigureSpec dataclasses and a PlotFamily registry (#636).
diff --git a/cellpy/plotting/__init__.py b/cellpy/plotting/__init__.py
index 38194004..1b31039b 100644
--- a/cellpy/plotting/__init__.py
+++ b/cellpy/plotting/__init__.py
@@ -14,16 +14,19 @@
| [`spec`][cellpy.plotting.spec] | ``FigureSpec`` / ``PanelSpec`` / ``AxisSpec`` |
| [`registry`][cellpy.plotting.registry] | named ``PlotFamily`` records for ``summary_plot`` |
| [`backends`][cellpy.plotting.backends] | render protocol + plotly formation layout (#637) |
+| [`prepare`][cellpy.plotting.prepare] | summary tidy-frame + ``FigureSpec`` (#638) |
+| [`context`][cellpy.plotting.context] | ``CellContext`` adapter (#638) |
The old locations re-export from here, so nothing that imported them breaks.
-The drawing code itself — ``summary_plot`` and friends — still lives in
-``plotutils``; Stage 1 of epic #567 moves selection behind the registry and
-lands the spec/backends scaffolding for prepare→spec→render (#638).
+Public ``summary_plot`` still lives in ``plotutils`` but runs
+prepare → spec → ``PlotlyBackend.render`` for the interactive path (#638);
+``SeabornPlotBuilder`` remains until #639.
"""
from __future__ import annotations
from cellpy.plotting.backends import Backend, PlotlyBackend
+from cellpy.plotting.context import CellContext, from_source
from cellpy.plotting.figures import (
load_figure,
load_matplotlib_figure,
@@ -32,6 +35,7 @@
save_matplotlib_figure,
)
from cellpy.plotting.labels import legend_replacer, remove_markers
+from cellpy.plotting.prepare import prepare_summary
from cellpy.plotting.registry import (
PlotFamily,
_register_family,
@@ -44,12 +48,14 @@
__all__ = [
"AxisSpec",
"Backend",
+ "CellContext",
"FigureSpec",
"PanelSpec",
"PlotFamily",
"PlotlyBackend",
"_register_family",
"families",
+ "from_source",
"get_family",
"legend_replacer",
"load_figure",
@@ -57,6 +63,7 @@
"load_plotly_figure",
"make_matplotlib_manager",
"make_plotly_template",
+ "prepare_summary",
"remove_markers",
"save_matplotlib_figure",
]
diff --git a/cellpy/plotting/backends/__init__.py b/cellpy/plotting/backends/__init__.py
index 769522bd..26a54348 100644
--- a/cellpy/plotting/backends/__init__.py
+++ b/cellpy/plotting/backends/__init__.py
@@ -1,4 +1,4 @@
-"""Plotting backends (prepare → spec → render) — epic #567 / #637."""
+"""Plotting backends (prepare → spec → render) — epic #567 / #637–#638."""
from __future__ import annotations
@@ -7,7 +7,6 @@
PlotlyBackend,
configure_formation_layout,
configure_fullcell_standard_domains,
- use_spec_render,
)
__all__ = [
@@ -15,5 +14,4 @@
"PlotlyBackend",
"configure_formation_layout",
"configure_fullcell_standard_domains",
- "use_spec_render",
]
diff --git a/cellpy/plotting/backends/plotly.py b/cellpy/plotting/backends/plotly.py
index 0126cbe1..986a60f3 100644
--- a/cellpy/plotting/backends/plotly.py
+++ b/cellpy/plotting/backends/plotly.py
@@ -1,15 +1,13 @@
-"""Plotly backend: generic formation/facet layout + render protocol (#637).
+"""Plotly backend: formation/facet layout + summary ``render`` (#637 / #638).
-The four ``PlotlyPlotBuilder._configure_formation_{1,2,3,4}_rows`` methods are
-collapsed into :func:`configure_formation_layout`. ``PlotlyPlotBuilder`` keeps
-ownership of ``px.line`` construction and the no-formation path; this module
-owns the formation axis grid.
+Formation axis grids live in :func:`configure_formation_layout`. Public
+``summary_plot`` draws through :class:`PlotlyBackend.render` from a tidy
+frame + :class:`~cellpy.plotting.spec.FigureSpec` (#638).
"""
from __future__ import annotations
import logging
-import os
import warnings
from copy import deepcopy
from typing import Any, Optional, Sequence
@@ -20,10 +18,6 @@
logger = logging.getLogger(__name__)
-#: Internal switch for the provisional ``PlotlyBackend.render`` path from
-#: ``summary_plot``. Default off — public prepare→spec→render lands in #638.
-SPEC_RENDER_ENV = "CELLPY_SUMMARY_PLOTLY_SPEC"
-
PLOTLY_BLANK_LABEL = {
"font": {},
"showarrow": False,
@@ -40,12 +34,6 @@
MAX_FORMATIONATION_ROWS = 4
-def use_spec_render() -> bool:
- """True when the provisional ``(frame, FigureSpec)`` render path is enabled."""
- value = os.environ.get(SPEC_RENDER_ENV, "").strip().lower()
- return value in {"1", "true", "yes", "on"}
-
-
def _label_dict(text: str, x: float, y: float) -> dict[str, Any]:
d = PLOTLY_BLANK_LABEL.copy()
d["text"] = text
@@ -293,48 +281,281 @@ def configure_fullcell_standard_domains(
class PlotlyBackend:
"""Plotly implementation of the :class:`~cellpy.plotting.backends.base.Backend` protocol.
- Full prepare→spec→render ownership arrives in #638. Until then this class
- is available for the optional ``CELLPY_SUMMARY_PLOTLY_SPEC`` path and for
- layout helpers used by ``PlotlyPlotBuilder``.
+ ``render(frame, spec)`` is the interactive path for public ``summary_plot``
+ (#638). Layout knobs are expected on ``spec.extras['render']`` as produced
+ by :mod:`cellpy.plotting.prepare.summary`.
"""
name = "plotly"
+ 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:
import plotly.express as px
extras = dict(spec.extras or {})
+ render = dict(extras.get("render") or {})
+ prepared = dict(extras.get("prepared_data_info") or {})
+
x = extras.get("x")
- y_header = extras.get("y_header", "value")
if x is None:
raise ValueError("FigureSpec.extras['x'] is required for PlotlyBackend.render")
- plotly_kwargs = dict(extras.get("plotly_kwargs") or {})
- labels = {
- x: (spec.x_axis.label or x),
- y_header: extras.get("y_label", y_header),
+ y_header = extras.get("y_header", self.y_header)
+ color = extras.get("color", self.color)
+ row = extras.get("row", self.row)
+ col_id = extras.get("col_id", self.col_id)
+ y = extras.get("y") or ""
+ y_label = extras.get("y_label", y_header)
+ x_label = prepared.get("x_label") or (spec.x_axis.label or x)
+ number_of_rows = extras.get("number_of_rows") or prepared.get(
+ "number_of_rows"
+ ) or len(spec.panels) or 1
+
+ additional_kwargs = dict(render.get("additional_kwargs") or {})
+ smart_link = additional_kwargs.pop("smart_link", True)
+ # Already consumed when building formation extras in prepare; drop so
+ # they are not forwarded into px.line.
+ additional_kwargs.pop("show_y_labels_on_right_pane", None)
+ additional_kwargs.pop("fullcell_standard_row_height_ratios", None)
+ additional_kwargs.pop("fullcell_standard_row_space", None)
+
+ plotly_update_traces = {}
+ for k in list(additional_kwargs.keys()):
+ if k.startswith("plotly_"):
+ plotly_update_traces[k.replace("plotly_", "")] = additional_kwargs.pop(k)
+
+ title = render.get("title")
+ if title is None:
+ title = spec.title
+ if title is None:
+ cell_name = extras.get("cell_name") or ""
+ title = f"Summary {cell_name}"
+
+ plotly_kwargs: dict[str, Any] = {
+ "color": color,
+ "height": render.get("height"),
+ "markers": render.get("markers", True),
+ "title": title,
+ "width": render.get("width", 900),
}
- if spec.title is not None:
- plotly_kwargs.setdefault("title", spec.title)
- fig = px.line(frame, x=x, y=y_header, labels=labels, **plotly_kwargs)
+ split = bool(render.get("split", True))
+ if split and row in frame.columns:
+ plotly_kwargs["facet_row"] = row
+
+ hover_columns = render.get("hover_columns") or []
+ if hover_columns:
+ present = [h for h in hover_columns if h in frame.columns]
+ if present:
+ plotly_kwargs["hover_data"] = present
+
+ if plotly_kwargs.get("height") is None:
+ if y.startswith("fullcell_standard_"):
+ plotly_kwargs["height"] = 800
+ elif split and number_of_rows > 1:
+ plotly_kwargs["height"] = 800
+ else:
+ plotly_kwargs["height"] = 200 + 200 * number_of_rows
+
+ # Lazy import avoids a circular import with plotutils at module load.
+ from cellpy.utils.plotutils import set_plotly_template
+
+ set_plotly_template(render.get("plotly_template"))
+
+ show_formation = bool(render.get("show_formation"))
+ if show_formation and col_id in frame.columns:
+ plotly_kwargs["facet_col"] = col_id
+
+ fig = px.line(
+ frame,
+ x=x,
+ y=y_header,
+ **plotly_kwargs,
+ labels={x: x_label, y_header: y_label},
+ **additional_kwargs,
+ )
+
+ if plotly_update_traces:
+ fig.update_traces(**plotly_update_traces)
+
+ if not render.get("show_legend", True):
+ fig.update_layout(showlegend=False)
+
+ y_range = render.get("y_range")
+ if y_range is not None:
+ fig.update_layout(yaxis=dict(range=y_range))
- if extras.get("show_formation"):
+ if show_formation:
+ formation = dict(render.get("formation_layout") or {})
+ if not formation:
+ raise ValueError(
+ "FigureSpec.extras['render']['formation_layout'] is required "
+ "when show_formation is True"
+ )
configure_formation_layout(
fig,
- n_rows=extras.get("n_rows") or len(spec.panels) or 1,
- x_axis_domain_formation=extras["x_axis_domain_formation"],
- x_axis_domain_rest=extras["x_axis_domain_rest"],
- x_axis_range_formation=extras["x_axis_range_formation"],
- x_axis_range_rest=extras["x_axis_range_rest"],
- show_y_labels_on_right_pane=extras.get(
+ n_rows=formation.get("n_rows") or number_of_rows,
+ x_axis_domain_formation=formation["x_axis_domain_formation"],
+ x_axis_domain_rest=formation["x_axis_domain_rest"],
+ x_axis_range_formation=formation["x_axis_range_formation"],
+ x_axis_range_rest=formation["x_axis_range_rest"],
+ show_y_labels_on_right_pane=formation.get(
"show_y_labels_on_right_pane", False
),
- row_y_ranges=extras.get("row_y_ranges"),
- top_row_label=extras.get("top_row_label"),
+ formation_header=DEFAULT_FORMATIONION_LABEL,
+ row_y_ranges=formation.get("row_y_ranges"),
+ top_row_label=formation.get("top_row_label"),
)
- fullcell = extras.get("fullcell_standard_domains")
+ fullcell = formation.get("fullcell_standard_domains")
if fullcell:
configure_fullcell_standard_domains(fig, **fullcell)
+ else:
+ self._configure_no_formation_axes(
+ fig, render.get("no_formation_layout") or {}
+ )
+
+ x_range = render.get("x_range")
+ if x_range is not None and not show_formation:
+ fig.update_layout(xaxis=dict(range=x_range))
+
+ if split:
+ if show_formation:
+ if not render.get("share_y") and not smart_link:
+ fig.update_yaxes(matches=None)
+ elif not render.get("share_y"):
+ fig.update_yaxes(matches=None)
+
+ if render.get("rangeslider"):
+ if show_formation:
+ logging.critical(
+ "Can not add rangeslider when showing formation cycles"
+ )
+ else:
+ fig.update_layout(xaxis_rangeslider_visible=True)
+
+ if render.get("auto_convert_legend_labels", True) and render.get(
+ "show_legend", True
+ ):
+ self._convert_legend_labels(fig)
return fig
+
+ def _configure_no_formation_axes(self, fig: Any, layout: dict[str, Any]) -> None:
+ """Configure axes when not showing formation cycles."""
+ y = layout.get("y") or ""
+ top_label = layout.get("top_row_label")
+ if top_label is not None:
+ fig.update_layout(
+ yaxis=dict(domain=[0.0, 0.65]),
+ yaxis2={
+ "title": dict(text=top_label),
+ "domain": [0.7, 1.0],
+ },
+ )
+ if not y.startswith("fullcell_standard_"):
+ return
+
+ plotly_row_ratios = layout.get("plotly_row_ratios") or [0.3, 0.6, 0.9]
+ plotly_row_space = layout.get("plotly_row_space", 0.02)
+ max_val_normalized_col = layout.get("max_val_normalized_col") or 0.0
+ eff_lim = layout.get("ce_range")
+
+ range_1 = eff_lim or auto_range(fig, "y4", "y4")
+ range_2 = layout.get("y_range") or auto_range(fig, "y3", "y3")
+ range_3 = auto_range(fig, "y2", "y2")
+ if layout.get("fullcell_standard_normalization_type") is not False:
+ range_3 = [
+ 0.0,
+ max(
+ max_val_normalized_col,
+ layout.get("fullcell_standard_normalization_scaler") or 1.0,
+ ),
+ ]
+ range_3 = layout.get("norm_range") or range_3
+ range_4 = layout.get("cv_share_range") or auto_range(fig, "y", "y")
+ fig.layout["annotations"] = 4 * [PLOTLY_BLANK_LABEL]
+
+ ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0
+ capacity_domain_start, capacity_domain_end = (
+ plotly_row_ratios[1],
+ plotly_row_ratios[2] - plotly_row_space,
+ )
+ loss_domain_start, loss_domain_end = (
+ plotly_row_ratios[0],
+ plotly_row_ratios[1] - plotly_row_space,
+ )
+ cv_domain_start, cv_domain_end = (
+ 0.0,
+ plotly_row_ratios[0] - plotly_row_space,
+ )
+
+ capacity_unit = layout.get("capacity_unit") or "-"
+ ce_label = "Coulombic
Efficiency (%)"
+ capacity_label = f"Capacity
({capacity_unit})"
+ if (
+ layout.get("fullcell_standard_normalization_type")
+ and layout.get("fullcell_standard_normalization_factor") is not None
+ ):
+ _norm_label = (
+ f"[{layout['fullcell_standard_normalization_scaler']:.1f}/"
+ f"{layout['fullcell_standard_normalization_factor']:.1f} "
+ f"{capacity_unit}]"
+ )
+ loss_label = f"Capacity
Retention (norm.)
{_norm_label}"
+ else:
+ loss_label = f"Capacity
Retention ({capacity_unit})"
+ cv_label = f"CV Capacity
({capacity_unit})"
+
+ fig.update_layout(
+ yaxis4={
+ "title": dict(text=ce_label),
+ "domain": [ce_domain_start, ce_domain_end],
+ "matches": None,
+ "range": range_1,
+ },
+ yaxis3={
+ "title": dict(text=capacity_label),
+ "domain": [capacity_domain_start, capacity_domain_end],
+ "matches": None,
+ "range": range_2,
+ },
+ yaxis2={
+ "title": dict(text=loss_label),
+ "domain": [loss_domain_start, loss_domain_end],
+ "matches": None,
+ "range": range_3,
+ },
+ yaxis={
+ "title": dict(text=cv_label),
+ "domain": [cv_domain_start, cv_domain_end],
+ "matches": None,
+ "range": range_4,
+ },
+ )
+
+ @staticmethod
+ def _convert_legend_labels(fig: Any) -> None:
+ """Convert legend labels to nicer format."""
+ for trace in fig.data:
+ name = trace.name
+ name = name.replace("_", " ").title()
+ name = name.replace("Gravimetric", "Grav.")
+ name = name.replace("Cv", "(CV)")
+ name = name.replace("Non (CV)", "(without CV)")
+ hover_template = trace.hovertemplate
+ if hover_template:
+ statements = []
+ for statement in hover_template.split("
"):
+ if "=" in statement:
+ variable, value = statement.split("=", 1)
+ if value.startswith("%{y}"):
+ variable = name
+ statement = "=".join((variable, value))
+ statements.append(statement)
+ hover_template = "
".join(statements)
+ trace.update(name=name, hovertemplate=hover_template)
diff --git a/cellpy/plotting/context.py b/cellpy/plotting/context.py
new file mode 100644
index 00000000..f7d278e9
--- /dev/null
+++ b/cellpy/plotting/context.py
@@ -0,0 +1,51 @@
+"""Source adapters for prepare → spec → render (#638).
+
+The only code in ``cellpy.plotting`` that should reach into ``CellpyCell`` /
+``Batch`` objects. BatchContext arrives with the collectors rebase.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class CellContext:
+ """Thin wrapper around a single ``CellpyCell`` for summary prepare/render."""
+
+ cell: Any
+
+ @property
+ def cell_name(self) -> str:
+ return getattr(self.cell, "cell_name", None) or ""
+
+ @property
+ def summary(self) -> Any:
+ return self.cell.data.summary
+
+ @property
+ def headers_summary(self) -> Any:
+ return self.cell.headers_summary
+
+ @property
+ def schema(self) -> Any:
+ return self.cell.schema
+
+ @property
+ def cellpy_units(self) -> Any:
+ return self.cell.cellpy_units
+
+ def make_summary(self, **kwargs: Any) -> Any:
+ return self.cell.make_summary(**kwargs)
+
+
+def from_source(source: Any) -> CellContext:
+ """Adapt *source* to a plotting context.
+
+ Currently only single-cell inputs are supported (#638). Batch / frame
+ adapters land with later epic stages.
+ """
+ if isinstance(source, CellContext):
+ return source
+ return CellContext(cell=source)
diff --git a/cellpy/plotting/prepare/__init__.py b/cellpy/plotting/prepare/__init__.py
new file mode 100644
index 00000000..c55c60e6
--- /dev/null
+++ b/cellpy/plotting/prepare/__init__.py
@@ -0,0 +1,7 @@
+"""Prepare stages for the prepare → spec → render pipeline (#638)."""
+
+from __future__ import annotations
+
+from cellpy.plotting.prepare.summary import prepare as prepare_summary
+
+__all__ = ["prepare_summary"]
diff --git a/cellpy/plotting/prepare/summary.py b/cellpy/plotting/prepare/summary.py
new file mode 100644
index 00000000..44bd5c86
--- /dev/null
+++ b/cellpy/plotting/prepare/summary.py
@@ -0,0 +1,722 @@
+"""Summary-plot prepare path: tidy frame + FigureSpec (#638).
+
+Extracted from ``cellpy.utils.plotutils.SummaryPlotDataPreparer``. Public
+``summary_plot`` calls :func:`prepare` then hands ``(frame, spec)`` to a
+backend renderer.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional
+
+import pandas as pd
+
+import cellpy.plotting.registry as registry
+from cellpy.plotting.context import from_source
+from cellpy.plotting.spec import AxisSpec, FigureSpec, PanelSpec
+
+logger = logging.getLogger(__name__)
+
+_NORMALIZED_CYCLE_INDEX = "normalized_cycle_index"
+Y_HEADER = "value"
+COLOR = "variable"
+ROW = "row"
+COL_ID = "cycle_type"
+
+
+def _plotly_top_row_label(y: str) -> Optional[str]:
+ if y.endswith("_efficiency"):
+ return "Coulombic Efficiency"
+ if y.endswith("_with_rate"):
+ return "C-rate (1/h)"
+ return None
+
+
+def _capacity_unit(c: Any, mode: str = "gravimetric") -> str:
+ from cellpy.exceptions import UnitsError
+ from cellpy.units import units_label
+
+ try:
+ return units_label("charge", mode, units=c.cellpy_units)
+ except UnitsError:
+ return "-"
+
+
+def _range_tuple(value: Any) -> Optional[tuple[float, float]]:
+ if value is None:
+ return None
+ return (float(value[0]), float(value[1]))
+
+
+def _build_formation_extras(
+ data: pd.DataFrame,
+ *,
+ x: str,
+ y: str,
+ config: Any,
+ number_of_rows: int,
+ max_cycle: Any,
+ min_cycle: Any,
+ max_val_normalized_col: float,
+ formation_cycle_selector: Any,
+ c: Any,
+ additional_kwargs: dict,
+) -> dict[str, Any]:
+ """Pre-compute formation / no-formation layout knobs for PlotlyBackend."""
+ show_y_labels_on_right_pane = additional_kwargs.get(
+ "show_y_labels_on_right_pane", False
+ )
+ plotly_row_ratios = additional_kwargs.get(
+ "fullcell_standard_row_height_ratios", [0.3, 0.6, 0.9]
+ )
+ plotly_row_space = additional_kwargs.get("fullcell_standard_row_space", 0.02)
+ top_row_label = _plotly_top_row_label(y) if number_of_rows == 2 else None
+ capacity_unit = None
+ if y.startswith("fullcell_standard_"):
+ capacity_unit = _capacity_unit(c, mode=y.split("_")[-1])
+
+ if config.show_formation:
+ x_axis_domain_formation = [
+ 0.0,
+ config.x_axis_domain_formation_fraction - config.column_separator / 2,
+ ]
+ x_axis_domain_rest = [
+ config.x_axis_domain_formation_fraction + config.column_separator / 2,
+ 0.95,
+ ]
+ max_cycle_formation = data.loc[formation_cycle_selector, x].max()
+ min_cycle_rest = data.loc[~formation_cycle_selector, x].min()
+ dd = 0.1 if x == _NORMALIZED_CYCLE_INDEX else 0.4
+ x_axis_range_formation = [min_cycle - dd, max_cycle_formation + dd]
+ x_axis_range_rest = [min_cycle_rest - dd, max_cycle + dd]
+ if config.x_range is not None:
+ x_axis_range_rest = [
+ x_axis_range_rest[0],
+ min(config.x_range[1], x_axis_range_rest[1]),
+ ]
+
+ row_y_ranges: list[Optional[list]] = [None] * number_of_rows
+ if number_of_rows == 2:
+ row_y_ranges[0] = config.y_range
+ row_y_ranges[1] = config.ce_range
+ elif number_of_rows == 4 and y.startswith("fullcell_standard_"):
+ row_y_ranges[0] = config.cv_share_range
+ if config.fullcell_standard_normalization_type is not False:
+ row_y_ranges[1] = config.norm_range or [
+ 0.0,
+ max(
+ max_val_normalized_col,
+ config.fullcell_standard_normalization_scaler,
+ ),
+ ]
+ row_y_ranges[2] = config.y_range
+ row_y_ranges[3] = config.ce_range
+
+ fullcell = None
+ if number_of_rows == 4 and y.startswith("fullcell_standard_"):
+ fullcell = {
+ "plotly_row_ratios": list(plotly_row_ratios),
+ "plotly_row_space": plotly_row_space,
+ "capacity_unit": capacity_unit,
+ "y": y,
+ "show_formation": True,
+ "x_axis_domain_formation_fraction": config.x_axis_domain_formation_fraction,
+ "link_capacity_scales": config.link_capacity_scales,
+ "normalization_type": config.fullcell_standard_normalization_type,
+ "normalization_factor": config.fullcell_standard_normalization_factor,
+ "normalization_scaler": config.fullcell_standard_normalization_scaler,
+ }
+
+ return {
+ "show_formation": True,
+ "formation_layout": {
+ "n_rows": number_of_rows,
+ "x_axis_domain_formation": x_axis_domain_formation,
+ "x_axis_domain_rest": x_axis_domain_rest,
+ "x_axis_range_formation": x_axis_range_formation,
+ "x_axis_range_rest": x_axis_range_rest,
+ "show_y_labels_on_right_pane": show_y_labels_on_right_pane,
+ "row_y_ranges": row_y_ranges,
+ "top_row_label": top_row_label,
+ "fullcell_standard_domains": fullcell,
+ },
+ "no_formation_layout": None,
+ "capacity_unit": capacity_unit,
+ "top_row_label": _plotly_top_row_label(y),
+ "plotly_row_ratios": list(plotly_row_ratios),
+ "plotly_row_space": plotly_row_space,
+ }
+
+ return {
+ "show_formation": False,
+ "formation_layout": None,
+ "no_formation_layout": {
+ "y": y,
+ "number_of_rows": number_of_rows,
+ "max_val_normalized_col": max_val_normalized_col,
+ "top_row_label": _plotly_top_row_label(y),
+ "capacity_unit": capacity_unit,
+ "plotly_row_ratios": list(plotly_row_ratios),
+ "plotly_row_space": plotly_row_space,
+ "ce_range": config.ce_range,
+ "y_range": config.y_range,
+ "norm_range": config.norm_range,
+ "cv_share_range": config.cv_share_range,
+ "fullcell_standard_normalization_type": config.fullcell_standard_normalization_type,
+ "fullcell_standard_normalization_factor": config.fullcell_standard_normalization_factor,
+ "fullcell_standard_normalization_scaler": config.fullcell_standard_normalization_scaler,
+ },
+ "capacity_unit": capacity_unit,
+ "top_row_label": _plotly_top_row_label(y),
+ "plotly_row_ratios": list(plotly_row_ratios),
+ "plotly_row_space": plotly_row_space,
+ }
+
+
+def _build_figure_spec(
+ prepared: dict[str, Any],
+ *,
+ family: Any,
+ config: Any,
+ c: Any,
+) -> FigureSpec:
+ data = prepared["data"]
+ x = config.x if config.x is not None else c.schema.summary.cycle_num
+ y = config.y
+ number_of_rows = prepared["number_of_rows"]
+ additional_kwargs = dict(getattr(config, "additional_kwargs", {}) or {})
+
+ panels = tuple(
+ PanelSpec(
+ columns=tuple(family.columns(c.headers_summary)) if i == 0 else (),
+ y_axis=AxisSpec(),
+ )
+ for i in range(max(number_of_rows, 1))
+ )
+
+ layout = _build_formation_extras(
+ data,
+ x=x,
+ y=y,
+ config=config,
+ number_of_rows=number_of_rows,
+ max_cycle=prepared["max_cycle"],
+ min_cycle=prepared["min_cycle"],
+ max_val_normalized_col=prepared["max_val_normalized_col"],
+ formation_cycle_selector=prepared["formation_cycle_selector"],
+ c=c,
+ additional_kwargs=additional_kwargs,
+ )
+
+ title = config.title
+ if title is None:
+ title = f"Summary {c.cell_name}"
+
+ extras: dict[str, Any] = {
+ "x": x,
+ "y": y,
+ "y_header": Y_HEADER,
+ "y_label": prepared["y_label"],
+ "color": COLOR,
+ "row": ROW,
+ "col_id": COL_ID,
+ "number_of_rows": number_of_rows,
+ "cell_name": c.cell_name,
+ "prepared_data_info": {
+ "number_of_rows": prepared["number_of_rows"],
+ "x_label": prepared["x_label"],
+ "y_label": prepared["y_label"],
+ "max_cycle": prepared["max_cycle"],
+ "min_cycle": prepared["min_cycle"],
+ "max_val_normalized_col": prepared["max_val_normalized_col"],
+ "formation_cycle_selector": prepared["formation_cycle_selector"],
+ },
+ "render": {
+ "height": config.height,
+ "width": config.width,
+ "markers": config.markers,
+ "title": title,
+ "split": config.split,
+ "hover_columns": list(config.hover_columns or []),
+ "show_legend": config.show_legend,
+ "show_formation": config.show_formation,
+ "share_y": config.share_y,
+ "rangeslider": config.rangeslider,
+ "auto_convert_legend_labels": config.auto_convert_legend_labels,
+ "y_range": config.y_range,
+ "x_range": config.x_range,
+ "plotly_template": config.plotly_template,
+ "additional_kwargs": additional_kwargs,
+ **layout,
+ },
+ }
+
+ return FigureSpec(
+ panels=panels,
+ x_axis=AxisSpec(label=prepared["x_label"], range=_range_tuple(config.x_range)),
+ title=title,
+ supports_formation=bool(getattr(family, "supports_formation", True))
+ and bool(config.show_formation),
+ extras=extras,
+ )
+
+
+def prepare(
+ source: Any,
+ family: Any,
+ config: Any,
+ plot_info: Any = None,
+) -> tuple[pd.DataFrame, FigureSpec]:
+ """Prepare a tidy summary frame and a :class:`FigureSpec`.
+
+ Args:
+ source: ``CellpyCell`` or :class:`~cellpy.plotting.context.CellContext`.
+ family: registered :class:`~cellpy.plotting.registry.PlotFamily`.
+ config: ``SummaryPlotConfig`` (or duck-typed equivalent).
+ plot_info: optional ``SummaryPlotInfo``; built from the cell when omitted.
+
+ Returns:
+ ``(frame, spec)`` — frame shape matches the pre-#638 preparer output.
+ """
+ ctx = from_source(source)
+ c = ctx.cell
+ if plot_info is None:
+ from cellpy.utils.plotutils import SummaryPlotInfo
+
+ plot_info = SummaryPlotInfo(c)
+
+ # Ensure registry agrees with config.y (family may be passed explicitly).
+ if getattr(family, "name", None) and family.name != config.y:
+ raise ValueError(
+ f"family.name {family.name!r} does not match config.y {config.y!r}"
+ )
+
+ prepared = SummaryPlotDataPreparer().prepare_data(c, config, plot_info)
+ spec = _build_figure_spec(prepared, family=family, config=config, c=c)
+ return prepared["data"], spec
+
+
+class SummaryPlotDataPreparer:
+ """Handles data collection and transformation for summary plots.
+
+ Data preparation 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 prepare_data(
+ self,
+ c: Any,
+ config: Any,
+ plot_info: Any,
+ ) -> dict:
+ """Prepare data for plotting.
+
+ Args:
+ c: cellpy object
+ config: SummaryPlotConfig with all parameters
+ summary_plot_info: SummaryPlotInfo containing information about pre-defined columns and labels
+ Returns:
+ Dictionary with keys:
+ - data: prepared DataFrame
+ - number_of_rows: number of rows for subplot layout
+ - x_label: x-axis label
+ - y_label: y-axis label
+ - max_cycle: maximum cycle number
+ - min_cycle: minimum cycle number
+ - max_val_normalized_col: max value for normalized columns
+ - formation_cycle_selector: boolean selector for formation cycles
+ """
+ x = config.x if config.x is not None else c.schema.summary.cycle_num
+ y = config.y
+ registry.get(y)
+
+ number_of_rows = 1
+ max_val_normalized_col = 0.0
+
+ if config.hover_columns and (
+ y.startswith("fullcell_standard_")
+ or y.endswith("_split_constant_voltage")
+ ):
+ logging.warning(
+ "summary_plot: hover_columns is currently only supported for "
+ "standard plot types; ignoring for y=%r",
+ y,
+ )
+
+ # Prepare data based on plot type
+ if y.startswith("fullcell_standard_"):
+ s, number_of_rows = self._prepare_fullcell_standard_data(
+ c, x, y, plot_info.y_cols, plot_info.y_trans, config
+ )
+ max_val_normalized_col = (
+ s.loc[s["variable"].str.contains("retention"), "value"].max()
+ if len(s.loc[s["variable"].str.contains("retention")]) > 0
+ else 0.0
+ )
+ elif y.endswith("_split_constant_voltage"):
+ s, number_of_rows = self._prepare_cv_split_data(
+ c, x, y, plot_info.y_cols, config
+ )
+ else:
+ s, number_of_rows = self._prepare_standard_data(
+ c, x, y, plot_info.y_cols, config
+ )
+
+ # Calculate cycle ranges
+ max_cycle = s[x].max()
+ min_cycle = s[x].min()
+
+ # Get labels
+ x_label = plot_info.x_axis_labels.get(x, x)
+ if y in plot_info.y_axis_label:
+ y_label = plot_info.y_axis_label.get(y, y)
+ else:
+ y_label = y.replace("_", " ").title()
+
+ # Mark formation cycles
+ formation_cycle_selector = self._mark_formation_cycles(
+ s, x, config.formation_cycles, self.col_id
+ )
+
+ return {
+ "data": s,
+ "number_of_rows": number_of_rows,
+ "x_label": x_label,
+ "y_label": y_label,
+ "max_cycle": max_cycle,
+ "min_cycle": min_cycle,
+ "max_val_normalized_col": max_val_normalized_col,
+ "formation_cycle_selector": formation_cycle_selector,
+ }
+
+ def _prepare_fullcell_standard_data(
+ self, c, x, y, y_cols, y_trans, config
+ ) -> tuple:
+ """Prepare data for fullcell_standard plots."""
+
+ # The figure has 4 rows: coulombic efficiency, capacity, capacity retention, and CV capacity
+ number_of_rows = 4
+ column_set = y_cols[y]
+
+ summary = self._preprocess_summary(c, c.data.summary, config)
+ if summary.index.name == x:
+ summary = summary.reset_index(drop=False)
+
+ # Get CV-only summary
+ summary_only_cv = c.make_summary(
+ selector_type="only-cv", create_copy=True
+ ).data.summary
+ if summary_only_cv.index.name == x:
+ summary_only_cv = summary_only_cv.reset_index(drop=False)
+
+ # Merge summaries
+ s = summary.merge(summary_only_cv, on=x, how="outer", suffixes=("", "_cv"))
+ s = s.reset_index(drop=True)
+ s = s.melt(x)
+ s = s.loc[s.variable.isin(column_set)]
+
+ s[self.row] = 1 # default row for capacity
+
+ # Set row numbers using regex patterns
+ s.loc[s["variable"].str.contains(r"_efficiency$"), self.row] = (
+ 0 # coulombic efficiency
+ )
+ s.loc[s["variable"].str.contains(r"cumulated.*loss"), self.row] = (
+ 2 # cumulated loss
+ )
+ s.loc[s["variable"].str.startswith(r"mod_01_"), self.row] = (
+ 2 # capacity retention
+ )
+ s.loc[s["variable"].str.contains(r"_cv$"), self.row] = 3 # cv data
+
+ # Reset losses if requested
+ if config.reset_losses:
+ logging.debug("Resetting losses")
+ first_values = (
+ s[s["variable"].str.contains(r"cumulated.*loss")]
+ .groupby("variable")["value"]
+ .transform("first")
+ )
+ mask = s["variable"].str.contains(r"cumulated.*loss")
+ s.loc[mask, "value"] = s.loc[mask, "value"] - first_values
+
+ # Apply normalization if requested
+ if config.fullcell_standard_normalization_type is not False:
+ logging.debug("Applying normalization")
+ s, max_val_normalized_col = self._apply_normalization(
+ s, y, y_trans, config, self.row
+ )
+
+ return s, number_of_rows
+
+ def _prepare_cv_split_data(self, c, x, y, y_cols, config) -> tuple:
+ """Prepare data for CV split plots."""
+ import warnings
+
+ if y.startswith("capacities_gravimetric"):
+ cap_type = "capacities_gravimetric"
+ elif y.startswith("capacities_areal"):
+ cap_type = "capacities_areal"
+ elif y.startswith("capacities_absolute"):
+ cap_type = "capacities_absolute"
+ else:
+ raise ValueError(f"Unknown capacity type for CV split: {y}")
+
+ column_set = y_cols[cap_type]
+
+ # Use partition_summary_cv_steps function (lives in plotutils for now)
+ from cellpy.utils.plotutils import partition_summary_cv_steps
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ s = partition_summary_cv_steps(
+ c, x, column_set, config.split, self.color, self.y_header
+ )
+
+ number_of_rows = 3 if config.split else 1
+
+ return s, number_of_rows
+
+ def _prepare_standard_data(self, c, x, y, y_cols, config) -> tuple:
+ """Prepare data for standard plots."""
+ column_set = y_cols[y]
+ if isinstance(column_set, str):
+ column_set = [column_set]
+
+ summary = self._preprocess_summary(c, c.data.summary, config)
+ summary = summary.reset_index()
+
+ # Check if requested columns exist in summary
+ # For absolute capacities, fall back to base columns if _absolute columns don't exist
+ available_columns = set(summary.columns)
+ requested_columns = set(column_set)
+ missing_columns = requested_columns - available_columns
+
+ if missing_columns and y == "capacities_absolute":
+ # For absolute capacities, if _absolute columns don't exist, use base columns
+ hdr = c.headers_summary
+ base_columns = [hdr.charge_capacity_raw, hdr.discharge_capacity_raw]
+ # Check if base columns exist
+ if all(col in available_columns for col in base_columns):
+ column_set = base_columns
+ else:
+ # If base columns also don't exist, keep original column_set
+ # This will result in empty DataFrame, which will be handled downstream
+ pass
+ elif missing_columns:
+ # For other capacity types, if columns are missing, keep original column_set
+ # This will result in empty DataFrame, which will be handled downstream
+ pass
+
+ hover_cols = list(config.hover_columns or [])
+ if hover_cols:
+ missing = [h for h in hover_cols if h not in summary.columns]
+ if missing:
+ logging.warning(
+ "summary_plot: dropping unknown hover_columns %s "
+ "(available: %s)",
+ missing,
+ sorted(summary.columns),
+ )
+ hover_cols = [h for h in hover_cols if h in summary.columns]
+ # Avoid duplicating x and value columns in id_vars
+ hover_cols = [h for h in hover_cols if h != x and h not in column_set]
+
+ id_vars = [x, *hover_cols]
+ s = summary.melt(id_vars=id_vars)
+ s = s.loc[s.variable.isin(column_set)]
+ s = s.reset_index(drop=True)
+
+ # Check if we have any data after filtering
+ if len(s) == 0:
+ raise ValueError(
+ f"No data found for plot type '{y}'. "
+ f"Requested columns: {column_set}. "
+ f"Available columns in summary: {list(available_columns)}"
+ )
+
+ s[self.row] = 1
+
+ number_of_rows = 1
+ if config.split:
+ if y.endswith("_efficiency"):
+ s[self.row] = 1
+ s.loc[s["variable"].str.contains("efficiency"), self.row] = 0
+ number_of_rows = 2
+ elif y.endswith("_with_rate"):
+ hdr = c.headers_summary
+ rate_cols = {hdr.charge_c_rate, hdr.discharge_c_rate}
+ s[self.row] = 1
+ s.loc[s["variable"].isin(rate_cols), self.row] = 0
+ number_of_rows = 2
+
+ return s, number_of_rows
+
+ def _apply_normalization(self, s, y, y_trans, config, row_col) -> tuple:
+ """Apply normalization transformations to data."""
+ import re
+ from collections.abc import Iterable
+
+ max_val_normalized_col = 0.0
+ normalization_factor = config.fullcell_standard_normalization_factor
+ normalization_type = config.fullcell_standard_normalization_type
+ normalization_cycle_numbers = (
+ config.fullcell_standard_normalization_cycle_numbers
+ )
+
+ # TODO: check if this is really needed!!
+ # Determine normalization factor if not provided
+ if normalization_factor is None:
+ logging.debug(
+ f"No normalization factor provided for {y}, using {normalization_type}"
+ )
+
+ if y.startswith("fullcell_standard_cumloss_") and normalization_type != "max":
+ logging.debug("only allowing for 'max' for cumloss plots")
+ normalization_type = "max"
+
+ if normalization_type in ["on-cycles", "on-cycle"]:
+ if normalization_cycle_numbers is None:
+ raise ValueError(
+ "Normalization cycle numbers are required for on-cycles normalization"
+ )
+ if isinstance(normalization_cycle_numbers, Iterable):
+ cycle_numbers = [cycle - 1 for cycle in normalization_cycle_numbers]
+ else:
+ cycle_numbers = [normalization_cycle_numbers - 1]
+ normalization_cycle_numbers = cycle_numbers
+
+ trans_kwargs = dict(
+ normalization_factor=normalization_factor,
+ normalization_type=normalization_type,
+ normalization_scaler=config.fullcell_standard_normalization_scaler,
+ normalization_indexes=normalization_cycle_numbers,
+ )
+
+ # Transform the data
+ max_row_val = s[row_col].max()
+ for col, trans_dict in y_trans.get(y, {}).items():
+ for (new_row_val, new_col), trans in trans_dict.items():
+ if new_col in s["variable"].values:
+ # Transforming on existing column
+ s.loc[s["variable"] == col, "value"] = trans(
+ s.loc[s["variable"] == col, "value"].values, **trans_kwargs
+ )
+ else:
+ # Creating new column
+ old_col = col
+ if new_row_val is not None:
+ row_val = new_row_val
+ else:
+ row_val = s.loc[s["variable"] == col, row_col]
+ if not row_val.empty:
+ row_val = row_val.values[0]
+ else:
+ max_row_val += 1
+ row_val = max_row_val
+
+ if old_col.startswith("mod_"):
+ old_col = re.sub(r"^mod_\d{2}_", "", old_col)
+ new_col_frame_section = s.loc[s["variable"] == old_col].copy()
+ new_col_frame_section["variable"] = new_col
+ new_col_frame_section[row_col] = row_val
+ transformed_values = trans(
+ new_col_frame_section["value"].values, **trans_kwargs
+ )
+ new_col_frame_section["value"] = transformed_values
+ s = pd.concat([s, new_col_frame_section], ignore_index=True)
+ s = s.reset_index(drop=True)
+ s = s.sort_values(by=[row_col, "variable"])
+
+ max_val_normalized_col = s.loc[s["variable"] == new_col, "value"].max()
+
+ return s, max_val_normalized_col
+
+ def _mark_formation_cycles(self, s, x, formation_cycles, col_id):
+ """Mark formation cycles in the data."""
+ formation_cycle_selector = slice(None, None)
+ if formation_cycles > 0:
+ formation_cycle_selector = s[x] <= formation_cycles
+ s[col_id] = "standard"
+ s.loc[formation_cycle_selector, col_id] = "formation"
+ return formation_cycle_selector
+
+ @staticmethod
+ def _preprocess_summary(c: Any, summary: pd.DataFrame, config) -> pd.DataFrame:
+ """Apply optional rate-rescaling and row filtering to a summary copy.
+
+ Two opt-in steps, both no-ops when their config field is ``None``:
+
+ * ``config.nominal_capacity`` rescales the existing
+ ``charge_c_rate`` / ``discharge_c_rate`` columns to use a new
+ nominal capacity instead of the one set on the cell. The
+ rescale factor is ``c.data.nom_cap / nominal_capacity``: since
+ ``rate = current / nom_cap``, the rate columns are multiplied
+ by ``old_nom_cap / new_nom_cap``.
+ * ``config.filters`` is forwarded to
+ :func:`cellpy.filters.filter_summary`. The default
+ ``rate_filter_columns`` resolves to both rate columns from
+ ``c.headers_summary`` (charge AND discharge).
+
+ Operates on a copy; the caller's ``summary`` argument is not
+ mutated.
+ """
+ out = summary.copy() if summary is not None else summary
+
+ if config.nominal_capacity is not None:
+ hdr = c.headers_summary
+ old_nom_cap = getattr(c.data, "nom_cap", None)
+ if old_nom_cap in (None, 0):
+ logging.warning(
+ "summary_plot: nominal_capacity override requested but "
+ "cell.data.nom_cap is %r; skipping rate rescale.",
+ old_nom_cap,
+ )
+ else:
+ scale = float(old_nom_cap) / float(config.nominal_capacity)
+ for col in (hdr.charge_c_rate, hdr.discharge_c_rate):
+ if col in out.columns:
+ out[col] = out[col] * scale
+ logging.debug(
+ "summary_plot: rescaled rate columns by %.6g "
+ "(old nom_cap=%s, new=%s)",
+ scale,
+ old_nom_cap,
+ config.nominal_capacity,
+ )
+
+ if config.filters:
+ from cellpy.filters import filter_summary
+
+ hdr = c.headers_summary
+ filter_kwargs = dict(config.filters)
+ if (
+ "rate" in filter_kwargs
+ and "rate_columns" not in filter_kwargs
+ ):
+ if config.rate_filter_columns is not None:
+ filter_kwargs["rate_columns"] = config.rate_filter_columns
+ else:
+ filter_kwargs["rate_columns"] = (
+ hdr.charge_c_rate,
+ hdr.discharge_c_rate,
+ )
+ before = len(out)
+ out = filter_summary(out, **filter_kwargs)
+ logging.debug(
+ "summary_plot: filters %s reduced rows %d -> %d",
+ filter_kwargs,
+ before,
+ len(out),
+ )
+
+ return out
+
+
diff --git a/cellpy/utils/plotutils.py b/cellpy/utils/plotutils.py
index 75ff1d00..26bbed78 100644
--- a/cellpy/utils/plotutils.py
+++ b/cellpy/utils/plotutils.py
@@ -37,12 +37,6 @@
# Single copies of the plotting plumbing that used to be duplicated across
# plotutils / collectors / batch_plotters (#567). Re-exported here so the
# `from cellpy.utils.plotutils import load_figure` spelling keeps working.
-from cellpy.plotting.backends.plotly import (
- DEFAULT_FORMATIONION_LABEL,
- auto_range as _plotly_auto_range,
- configure_formation_layout,
- configure_fullcell_standard_domains,
-)
from cellpy.plotting.figures import ( # noqa: F401
load_figure,
load_matplotlib_figure,
@@ -738,8 +732,8 @@ def __post_init__(self) -> None:
# block to draw, so ``show_formation`` must be False regardless of
# how the caller set it. Without this, ``_mark_formation_cycles``
# returns the ``slice(None, None)`` sentinel while ``show_formation``
- # stays True, and ``_configure_formation_axes`` then evaluates
- # ``~slice(...)`` which raises ``TypeError``. See issue #366.
+ # stays True, and formation layout then evaluates ``~slice(...)``
+ # which raises ``TypeError``. See issue #366.
if self.formation_cycles is None:
self.formation_cycles = 0
self.formation_cycles = int(self.formation_cycles)
@@ -1005,854 +999,6 @@ def _create_col_info(self, c: Any) -> tuple[tuple, dict, dict, dict]:
self.y_trans = y_transformations
-class SummaryPlotDataPreparer:
- """Handles data collection and transformation for summary plots.
-
- Data preparation 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 prepare_data(
- self,
- c: Any,
- config: SummaryPlotConfig,
- plot_info: SummaryPlotInfo,
- ) -> dict:
- """Prepare data for plotting.
-
- Args:
- c: cellpy object
- config: SummaryPlotConfig with all parameters
- summary_plot_info: SummaryPlotInfo containing information about pre-defined columns and labels
- Returns:
- Dictionary with keys:
- - data: prepared DataFrame
- - number_of_rows: number of rows for subplot layout
- - x_label: x-axis label
- - y_label: y-axis label
- - max_cycle: maximum cycle number
- - min_cycle: minimum cycle number
- - max_val_normalized_col: max value for normalized columns
- - formation_cycle_selector: boolean selector for formation cycles
- """
- x = config.x if config.x is not None else c.schema.summary.cycle_num
- y = config.y
- plot_registry.get(y)
-
- number_of_rows = 1
- max_val_normalized_col = 0.0
-
- if config.hover_columns and (
- y.startswith("fullcell_standard_")
- or y.endswith("_split_constant_voltage")
- ):
- logging.warning(
- "summary_plot: hover_columns is currently only supported for "
- "standard plot types; ignoring for y=%r",
- y,
- )
-
- # Prepare data based on plot type
- if y.startswith("fullcell_standard_"):
- s, number_of_rows = self._prepare_fullcell_standard_data(
- c, x, y, plot_info.y_cols, plot_info.y_trans, config
- )
- max_val_normalized_col = (
- s.loc[s["variable"].str.contains("retention"), "value"].max()
- if len(s.loc[s["variable"].str.contains("retention")]) > 0
- else 0.0
- )
- elif y.endswith("_split_constant_voltage"):
- s, number_of_rows = self._prepare_cv_split_data(
- c, x, y, plot_info.y_cols, config
- )
- else:
- s, number_of_rows = self._prepare_standard_data(
- c, x, y, plot_info.y_cols, config
- )
-
- # Calculate cycle ranges
- max_cycle = s[x].max()
- min_cycle = s[x].min()
-
- # Get labels
- x_label = plot_info.x_axis_labels.get(x, x)
- if y in plot_info.y_axis_label:
- y_label = plot_info.y_axis_label.get(y, y)
- else:
- y_label = y.replace("_", " ").title()
-
- # Mark formation cycles
- formation_cycle_selector = self._mark_formation_cycles(
- s, x, config.formation_cycles, self.col_id
- )
-
- return {
- "data": s,
- "number_of_rows": number_of_rows,
- "x_label": x_label,
- "y_label": y_label,
- "max_cycle": max_cycle,
- "min_cycle": min_cycle,
- "max_val_normalized_col": max_val_normalized_col,
- "formation_cycle_selector": formation_cycle_selector,
- }
-
- def _prepare_fullcell_standard_data(
- self, c, x, y, y_cols, y_trans, config
- ) -> tuple:
- """Prepare data for fullcell_standard plots."""
-
- # The figure has 4 rows: coulombic efficiency, capacity, capacity retention, and CV capacity
- number_of_rows = 4
- column_set = y_cols[y]
-
- summary = self._preprocess_summary(c, c.data.summary, config)
- if summary.index.name == x:
- summary = summary.reset_index(drop=False)
-
- # Get CV-only summary
- summary_only_cv = c.make_summary(
- selector_type="only-cv", create_copy=True
- ).data.summary
- if summary_only_cv.index.name == x:
- summary_only_cv = summary_only_cv.reset_index(drop=False)
-
- # Merge summaries
- s = summary.merge(summary_only_cv, on=x, how="outer", suffixes=("", "_cv"))
- s = s.reset_index(drop=True)
- s = s.melt(x)
- s = s.loc[s.variable.isin(column_set)]
-
- s[self.row] = 1 # default row for capacity
-
- # Set row numbers using regex patterns
- s.loc[s["variable"].str.contains(r"_efficiency$"), self.row] = (
- 0 # coulombic efficiency
- )
- s.loc[s["variable"].str.contains(r"cumulated.*loss"), self.row] = (
- 2 # cumulated loss
- )
- s.loc[s["variable"].str.startswith(r"mod_01_"), self.row] = (
- 2 # capacity retention
- )
- s.loc[s["variable"].str.contains(r"_cv$"), self.row] = 3 # cv data
-
- # Reset losses if requested
- if config.reset_losses:
- logging.debug("Resetting losses")
- first_values = (
- s[s["variable"].str.contains(r"cumulated.*loss")]
- .groupby("variable")["value"]
- .transform("first")
- )
- mask = s["variable"].str.contains(r"cumulated.*loss")
- s.loc[mask, "value"] = s.loc[mask, "value"] - first_values
-
- # Apply normalization if requested
- if config.fullcell_standard_normalization_type is not False:
- logging.debug("Applying normalization")
- s, max_val_normalized_col = self._apply_normalization(
- s, y, y_trans, config, self.row
- )
-
- return s, number_of_rows
-
- def _prepare_cv_split_data(self, c, x, y, y_cols, config) -> tuple:
- """Prepare data for CV split plots."""
- import warnings
-
- if y.startswith("capacities_gravimetric"):
- cap_type = "capacities_gravimetric"
- elif y.startswith("capacities_areal"):
- cap_type = "capacities_areal"
- elif y.startswith("capacities_absolute"):
- cap_type = "capacities_absolute"
- else:
- raise ValueError(f"Unknown capacity type for CV split: {y}")
-
- column_set = y_cols[cap_type]
-
- # Use partition_summary_cv_steps function
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- s = partition_summary_cv_steps(
- c, x, column_set, config.split, self.color, self.y_header
- )
-
- number_of_rows = 3 if config.split else 1
-
- return s, number_of_rows
-
- def _prepare_standard_data(self, c, x, y, y_cols, config) -> tuple:
- """Prepare data for standard plots."""
- column_set = y_cols[y]
- if isinstance(column_set, str):
- column_set = [column_set]
-
- summary = self._preprocess_summary(c, c.data.summary, config)
- summary = summary.reset_index()
-
- # Check if requested columns exist in summary
- # For absolute capacities, fall back to base columns if _absolute columns don't exist
- available_columns = set(summary.columns)
- requested_columns = set(column_set)
- missing_columns = requested_columns - available_columns
-
- if missing_columns and y == "capacities_absolute":
- # For absolute capacities, if _absolute columns don't exist, use base columns
- hdr = c.headers_summary
- base_columns = [hdr.charge_capacity_raw, hdr.discharge_capacity_raw]
- # Check if base columns exist
- if all(col in available_columns for col in base_columns):
- column_set = base_columns
- else:
- # If base columns also don't exist, keep original column_set
- # This will result in empty DataFrame, which will be handled downstream
- pass
- elif missing_columns:
- # For other capacity types, if columns are missing, keep original column_set
- # This will result in empty DataFrame, which will be handled downstream
- pass
-
- hover_cols = list(config.hover_columns or [])
- if hover_cols:
- missing = [h for h in hover_cols if h not in summary.columns]
- if missing:
- logging.warning(
- "summary_plot: dropping unknown hover_columns %s "
- "(available: %s)",
- missing,
- sorted(summary.columns),
- )
- hover_cols = [h for h in hover_cols if h in summary.columns]
- # Avoid duplicating x and value columns in id_vars
- hover_cols = [h for h in hover_cols if h != x and h not in column_set]
-
- id_vars = [x, *hover_cols]
- s = summary.melt(id_vars=id_vars)
- s = s.loc[s.variable.isin(column_set)]
- s = s.reset_index(drop=True)
-
- # Check if we have any data after filtering
- if len(s) == 0:
- raise ValueError(
- f"No data found for plot type '{y}'. "
- f"Requested columns: {column_set}. "
- f"Available columns in summary: {list(available_columns)}"
- )
-
- s[self.row] = 1
-
- number_of_rows = 1
- if config.split:
- if y.endswith("_efficiency"):
- s[self.row] = 1
- s.loc[s["variable"].str.contains("efficiency"), self.row] = 0
- number_of_rows = 2
- elif y.endswith("_with_rate"):
- hdr = c.headers_summary
- rate_cols = {hdr.charge_c_rate, hdr.discharge_c_rate}
- s[self.row] = 1
- s.loc[s["variable"].isin(rate_cols), self.row] = 0
- number_of_rows = 2
-
- return s, number_of_rows
-
- def _apply_normalization(self, s, y, y_trans, config, row_col) -> tuple:
- """Apply normalization transformations to data."""
- import re
- from collections.abc import Iterable
-
- max_val_normalized_col = 0.0
- normalization_factor = config.fullcell_standard_normalization_factor
- normalization_type = config.fullcell_standard_normalization_type
- normalization_cycle_numbers = (
- config.fullcell_standard_normalization_cycle_numbers
- )
-
- # TODO: check if this is really needed!!
- # Determine normalization factor if not provided
- if normalization_factor is None:
- logging.debug(
- f"No normalization factor provided for {y}, using {normalization_type}"
- )
-
- if y.startswith("fullcell_standard_cumloss_") and normalization_type != "max":
- logging.debug("only allowing for 'max' for cumloss plots")
- normalization_type = "max"
-
- if normalization_type in ["on-cycles", "on-cycle"]:
- if normalization_cycle_numbers is None:
- raise ValueError(
- "Normalization cycle numbers are required for on-cycles normalization"
- )
- if isinstance(normalization_cycle_numbers, Iterable):
- cycle_numbers = [cycle - 1 for cycle in normalization_cycle_numbers]
- else:
- cycle_numbers = [normalization_cycle_numbers - 1]
- normalization_cycle_numbers = cycle_numbers
-
- trans_kwargs = dict(
- normalization_factor=normalization_factor,
- normalization_type=normalization_type,
- normalization_scaler=config.fullcell_standard_normalization_scaler,
- normalization_indexes=normalization_cycle_numbers,
- )
-
- # Transform the data
- max_row_val = s[row_col].max()
- for col, trans_dict in y_trans.get(y, {}).items():
- for (new_row_val, new_col), trans in trans_dict.items():
- if new_col in s["variable"].values:
- # Transforming on existing column
- s.loc[s["variable"] == col, "value"] = trans(
- s.loc[s["variable"] == col, "value"].values, **trans_kwargs
- )
- else:
- # Creating new column
- old_col = col
- if new_row_val is not None:
- row_val = new_row_val
- else:
- row_val = s.loc[s["variable"] == col, row_col]
- if not row_val.empty:
- row_val = row_val.values[0]
- else:
- max_row_val += 1
- row_val = max_row_val
-
- if old_col.startswith("mod_"):
- old_col = re.sub(r"^mod_\d{2}_", "", old_col)
- new_col_frame_section = s.loc[s["variable"] == old_col].copy()
- new_col_frame_section["variable"] = new_col
- new_col_frame_section[row_col] = row_val
- transformed_values = trans(
- new_col_frame_section["value"].values, **trans_kwargs
- )
- new_col_frame_section["value"] = transformed_values
- s = pd.concat([s, new_col_frame_section], ignore_index=True)
- s = s.reset_index(drop=True)
- s = s.sort_values(by=[row_col, "variable"])
-
- max_val_normalized_col = s.loc[s["variable"] == new_col, "value"].max()
-
- return s, max_val_normalized_col
-
- def _mark_formation_cycles(self, s, x, formation_cycles, col_id):
- """Mark formation cycles in the data."""
- formation_cycle_selector = slice(None, None)
- if formation_cycles > 0:
- formation_cycle_selector = s[x] <= formation_cycles
- s[col_id] = "standard"
- s.loc[formation_cycle_selector, col_id] = "formation"
- return formation_cycle_selector
-
- @staticmethod
- def _preprocess_summary(c: Any, summary: pd.DataFrame, config) -> pd.DataFrame:
- """Apply optional rate-rescaling and row filtering to a summary copy.
-
- Two opt-in steps, both no-ops when their config field is ``None``:
-
- * ``config.nominal_capacity`` rescales the existing
- ``charge_c_rate`` / ``discharge_c_rate`` columns to use a new
- nominal capacity instead of the one set on the cell. The
- rescale factor is ``c.data.nom_cap / nominal_capacity``: since
- ``rate = current / nom_cap``, the rate columns are multiplied
- by ``old_nom_cap / new_nom_cap``.
- * ``config.filters`` is forwarded to
- :func:`cellpy.filters.filter_summary`. The default
- ``rate_filter_columns`` resolves to both rate columns from
- ``c.headers_summary`` (charge AND discharge).
-
- Operates on a copy; the caller's ``summary`` argument is not
- mutated.
- """
- out = summary.copy() if summary is not None else summary
-
- if config.nominal_capacity is not None:
- hdr = c.headers_summary
- old_nom_cap = getattr(c.data, "nom_cap", None)
- if old_nom_cap in (None, 0):
- logging.warning(
- "summary_plot: nominal_capacity override requested but "
- "cell.data.nom_cap is %r; skipping rate rescale.",
- old_nom_cap,
- )
- else:
- scale = float(old_nom_cap) / float(config.nominal_capacity)
- for col in (hdr.charge_c_rate, hdr.discharge_c_rate):
- if col in out.columns:
- out[col] = out[col] * scale
- logging.debug(
- "summary_plot: rescaled rate columns by %.6g "
- "(old nom_cap=%s, new=%s)",
- scale,
- old_nom_cap,
- config.nominal_capacity,
- )
-
- if config.filters:
- from cellpy.filters import filter_summary
-
- hdr = c.headers_summary
- filter_kwargs = dict(config.filters)
- if (
- "rate" in filter_kwargs
- and "rate_columns" not in filter_kwargs
- ):
- if config.rate_filter_columns is not None:
- filter_kwargs["rate_columns"] = config.rate_filter_columns
- else:
- filter_kwargs["rate_columns"] = (
- hdr.charge_c_rate,
- hdr.discharge_c_rate,
- )
- before = len(out)
- out = filter_summary(out, **filter_kwargs)
- logging.debug(
- "summary_plot: filters %s reduced rows %d -> %d",
- filter_kwargs,
- before,
- len(out),
- )
-
- return out
-
-
-class PlotlyPlotBuilder:
- """Handles Plotly-specific plotting logic for summary plots.
-
- Plotly 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 Plotly figure from prepared data.
-
- Args:
- data: Prepared DataFrame from SummaryPlotDataPreparer
- prepared_data_info: Dictionary with metadata from data preparer
- config: SummaryPlotConfig with all parameters
- additional_kwargs: Additional kwargs for plotly (from legacy function)
- c: cellpy object (needed for some label generation)
-
- Returns:
- Plotly figure object
- """
- import plotly.express as px
-
- # Extract plotly-specific parameters from additional_kwargs
- smart_link = additional_kwargs.pop("smart_link", True)
- show_y_labels_on_right_pane = additional_kwargs.pop(
- "show_y_labels_on_right_pane", False
- )
- plotly_row_ratios = additional_kwargs.pop(
- "fullcell_standard_row_height_ratios", [0.3, 0.6, 0.9]
- )
- plotly_row_space = additional_kwargs.pop("fullcell_standard_row_space", 0.02)
-
- # Extract plotly_* parameters for update_traces
- plotly_update_traces = {}
- for k in list(additional_kwargs.keys()):
- if k.startswith("plotly_"):
- plotly_update_traces[k.replace("plotly_", "")] = additional_kwargs.pop(
- k
- )
-
- # 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"]
- min_cycle = prepared_data_info["min_cycle"]
- max_val_normalized_col = prepared_data_info["max_val_normalized_col"]
- formation_cycle_selector = prepared_data_info["formation_cycle_selector"]
-
- # Prepare plotly kwargs
- plotly_kwargs = {
- "color": self.color,
- "height": config.height,
- "markers": config.markers,
- "title": title,
- "width": config.width,
- }
-
- # Add facet_row if split
- if config.split and self.row in data.columns:
- plotly_kwargs["facet_row"] = self.row
-
- # Add hover columns if they survived data preparation
- if config.hover_columns:
- present = [h for h in config.hover_columns if h in data.columns]
- if present:
- plotly_kwargs["hover_data"] = present
-
- # Set default height if not provided
- if plotly_kwargs.get("height") is None:
- if y.startswith("fullcell_standard_"):
- plotly_kwargs["height"] = 800
- elif config.split and number_of_rows > 1:
- plotly_kwargs["height"] = 800
- else:
- plotly_kwargs["height"] = 200 + 200 * number_of_rows
-
- # Set plotly template
- set_plotly_template(config.plotly_template)
-
- # Add facet_col for formation cycles
- if config.show_formation and self.col_id in data.columns:
- plotly_kwargs["facet_col"] = self.col_id
-
- # Create base figure
-
- fig = px.line(
- data,
- x=x,
- y=self.y_header,
- **plotly_kwargs,
- labels={
- x: x_label,
- self.y_header: y_label,
- },
- **additional_kwargs,
- )
-
- # Update traces
- if plotly_update_traces:
- fig.update_traces(**plotly_update_traces)
-
- # Hide legend if requested
- if not config.show_legend:
- fig.update_layout(showlegend=False)
-
- # Apply y_range if provided
- if config.y_range is not None:
- fig.update_layout(yaxis=dict(range=config.y_range))
-
- # Configure formation cycles and subplot layouts
- if config.show_formation:
- self._configure_formation_axes(
- fig,
- data,
- x,
- config,
- number_of_rows,
- max_cycle,
- min_cycle,
- formation_cycle_selector,
- show_y_labels_on_right_pane,
- y,
- max_val_normalized_col,
- plotly_row_ratios,
- plotly_row_space,
- c,
- )
- else:
- # Configure without formation cycles
- self._configure_no_formation_axes(
- fig,
- config,
- y,
- number_of_rows,
- max_val_normalized_col,
- plotly_row_ratios,
- plotly_row_space,
- c,
- )
-
- # Apply x_range if provided
- if config.x_range is not None:
- if not config.show_formation:
- fig.update_layout(xaxis=dict(range=config.x_range))
-
- # Handle split and share_y
- if config.split:
- if config.show_formation:
- if not config.share_y and not smart_link:
- fig.update_yaxes(matches=None)
- elif not config.share_y:
- fig.update_yaxes(matches=None)
-
- # Add rangeslider if requested
- if config.rangeslider:
- if config.show_formation:
- logging.critical(
- "Can not add rangeslider when showing formation cycles"
- )
- else:
- fig.update_layout(xaxis_rangeslider_visible=True)
-
- # Auto-convert legend labels
- if config.auto_convert_legend_labels and config.show_legend:
- self._convert_legend_labels(fig)
-
- return fig
-
- def _auto_range(self, fig: Any, axis_name_1: str, axis_name_2: str) -> list:
- """Calculate auto range for two y-axes (only works for plotly)."""
- return _plotly_auto_range(fig, axis_name_1, axis_name_2)
-
- def _configure_formation_axes(
- self,
- fig,
- data,
- x,
- config,
- number_of_rows,
- max_cycle,
- min_cycle,
- formation_cycle_selector,
- show_y_labels_on_right_pane,
- y,
- max_val_normalized_col,
- plotly_row_ratios,
- plotly_row_space,
- c,
- ):
- """Configure axes when showing formation cycles.
-
- Thin adapter over :func:`cellpy.plotting.backends.plotly.configure_formation_layout`
- (#637). Per-row-count methods are gone; the N-row grid lives in the backend.
- """
- x_axis_domain_formation = [
- 0.0,
- config.x_axis_domain_formation_fraction - config.column_separator / 2,
- ]
- x_axis_domain_rest = [
- config.x_axis_domain_formation_fraction + config.column_separator / 2,
- 0.95,
- ]
- max_cycle_formation = data.loc[formation_cycle_selector, x].max()
- min_cycle_rest = data.loc[~formation_cycle_selector, x].min()
-
- if x == _NORMALIZED_CYCLE_INDEX:
- dd = 0.1
- else:
- dd = 0.4
- x_axis_range_formation = [min_cycle - dd, max_cycle_formation + dd]
- x_axis_range_rest = [min_cycle_rest - dd, max_cycle + dd]
-
- if config.x_range is not None:
- x_axis_range_rest = [
- x_axis_range_rest[0],
- min(config.x_range[1], x_axis_range_rest[1]),
- ]
-
- eff_lim = config.ce_range
- top_row_label = _plotly_top_row_label(y) if number_of_rows == 2 else None
-
- # Pre-resolve per-row y ranges that the old per-N methods special-cased.
- # ``None`` entries mean "auto-range that facet-row pair".
- row_y_ranges: list[Optional[list]] = [None] * number_of_rows
- if number_of_rows == 2:
- row_y_ranges[0] = config.y_range
- row_y_ranges[1] = eff_lim
- elif number_of_rows == 4 and y.startswith("fullcell_standard_"):
- # Row order matches the old 4-row method: 0=CV, 1=retention/norm,
- # 2=capacity, 3=CE. ``None`` → auto-range inside the layout helper.
- row_y_ranges[0] = config.cv_share_range
- if config.fullcell_standard_normalization_type is not False:
- row_y_ranges[1] = config.norm_range or [
- 0.0,
- max(
- max_val_normalized_col,
- config.fullcell_standard_normalization_scaler,
- ),
- ]
- row_y_ranges[2] = config.y_range
- row_y_ranges[3] = config.ce_range
-
- configure_formation_layout(
- fig,
- n_rows=number_of_rows,
- x_axis_domain_formation=x_axis_domain_formation,
- x_axis_domain_rest=x_axis_domain_rest,
- x_axis_range_formation=x_axis_range_formation,
- x_axis_range_rest=x_axis_range_rest,
- show_y_labels_on_right_pane=show_y_labels_on_right_pane,
- formation_header=DEFAULT_FORMATIONION_LABEL,
- row_y_ranges=row_y_ranges,
- top_row_label=top_row_label,
- )
-
- if number_of_rows == 4 and y.startswith("fullcell_standard_"):
- self._configure_fullcell_standard_domains(
- fig,
- config,
- plotly_row_ratios,
- plotly_row_space,
- c,
- y,
- )
-
- def _configure_fullcell_standard_domains(
- self,
- fig,
- config,
- plotly_row_ratios,
- plotly_row_space,
- c,
- y,
- ):
- """Configure domain layout for fullcell_standard plots."""
- mode = y.split("_")[-1]
- configure_fullcell_standard_domains(
- fig,
- plotly_row_ratios=plotly_row_ratios,
- plotly_row_space=plotly_row_space,
- capacity_unit=_get_capacity_unit(c, mode=mode),
- y=y,
- show_formation=config.show_formation,
- x_axis_domain_formation_fraction=config.x_axis_domain_formation_fraction,
- link_capacity_scales=config.link_capacity_scales,
- normalization_type=config.fullcell_standard_normalization_type,
- normalization_factor=config.fullcell_standard_normalization_factor,
- normalization_scaler=config.fullcell_standard_normalization_scaler,
- )
-
- def _configure_no_formation_axes(
- self,
- fig,
- config,
- y,
- number_of_rows,
- max_val_normalized_col,
- plotly_row_ratios,
- plotly_row_space,
- c,
- ):
- """Configure axes when not showing formation cycles."""
- eff_lim = config.ce_range
-
- _top_label = _plotly_top_row_label(y)
- if _top_label is not None:
- fig.update_layout(
- yaxis=dict(domain=[0.0, 0.65]),
- yaxis2={
- "title": dict(text=_top_label),
- "domain": [0.7, 1.0],
- },
- )
- if y.startswith("fullcell_standard_"):
- range_1 = eff_lim or self._auto_range(fig, "y4", "y4")
- range_2 = config.y_range or self._auto_range(fig, "y3", "y3")
- range_3 = self._auto_range(fig, "y2", "y2")
- if config.fullcell_standard_normalization_type is not False:
- range_3 = [
- 0.0,
- max(
- max_val_normalized_col,
- config.fullcell_standard_normalization_scaler,
- ),
- ]
- range_3 = config.norm_range or range_3
-
- range_4 = config.cv_share_range or self._auto_range(fig, "y", "y")
- fig.layout["annotations"] = 4 * [PLOTLY_BLANK_LABEL]
-
- ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0
- capacity_domain_start, capacity_domain_end = (
- plotly_row_ratios[1],
- plotly_row_ratios[2] - plotly_row_space,
- )
- loss_domain_start, loss_domain_end = (
- plotly_row_ratios[0],
- plotly_row_ratios[1] - plotly_row_space,
- )
- cv_domain_start, cv_domain_end = (
- 0.0,
- plotly_row_ratios[0] - plotly_row_space,
- )
-
- # Format y-axis labels with HTML for proper alignment
- capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1])
- ce_label = "Coulombic
Efficiency (%)"
- capacity_label = f"Capacity
({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
Retention (norm.)
{_norm_label}"
- else:
- loss_label = f"Capacity
Retention ({capacity_unit})"
- cv_label = f"CV Capacity
({capacity_unit})"
-
- fig.update_layout(
- yaxis4={
- "title": dict(text=ce_label),
- "domain": [ce_domain_start, ce_domain_end],
- "matches": None,
- "range": range_1,
- },
- yaxis3={
- "title": dict(text=capacity_label),
- "domain": [capacity_domain_start, capacity_domain_end],
- "matches": None,
- "range": range_2,
- },
- yaxis2={
- "title": dict(text=loss_label),
- "domain": [loss_domain_start, loss_domain_end],
- "matches": None,
- "range": range_3,
- },
- yaxis={
- "title": dict(text=cv_label),
- "domain": [cv_domain_start, cv_domain_end],
- "matches": None,
- "range": range_4,
- },
- )
-
- def _convert_legend_labels(self, fig):
- """Convert legend labels to nicer format."""
- for trace in fig.data:
- name = trace.name
- name = name.replace("_", " ").title()
- name = name.replace("Gravimetric", "Grav.")
- name = name.replace("Cv", "(CV)")
- name = name.replace("Non (CV)", "(without CV)")
- hover_template = trace.hovertemplate
- if hover_template:
- statements = []
- for statement in hover_template.split("
"):
- if "=" in statement:
- variable, value = statement.split("=", 1)
- if value.startswith("%{y}"):
- variable = name
- statement = "=".join((variable, value))
- statements.append(statement)
- hover_template = "
".join(statements)
- trace.update(name=name, hovertemplate=hover_template)
-
-
class SeabornPlotBuilder:
"""Handles Seaborn-specific plotting logic for summary plots.
@@ -1877,8 +1023,8 @@ def build_plot(
"""Build Seaborn/Matplotlib figure from prepared data.
Args:
- data: Prepared DataFrame from SummaryPlotDataPreparer
- prepared_data_info: Dictionary with metadata from data preparer
+ 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)
@@ -3041,27 +2187,34 @@ def summary_plot(
)
return None
- # Prepare data
- plot_info = SummaryPlotInfo(c)
- preparer = SummaryPlotDataPreparer()
- prepared_data_info = preparer.prepare_data(
- c,
- config,
- plot_info,
- )
+ # prepare → spec → render (#638)
+ from cellpy.plotting.backends.plotly import PlotlyBackend
+ from cellpy.plotting.context import from_source
+ from cellpy.plotting.prepare.summary import prepare as prepare_summary
- builder = PlotlyPlotBuilder() if config.interactive else SeabornPlotBuilder()
+ plot_info = SummaryPlotInfo(c)
+ family = plot_registry.get(config.y)
+ ctx = from_source(c)
+ frame, spec = prepare_summary(ctx, family, config, plot_info=plot_info)
- fig = builder.build_plot(
- prepared_data_info["data"],
- prepared_data_info,
- config,
- config.additional_kwargs,
- c,
- )
+ 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,
+ )
if config.return_data:
- return fig, prepared_data_info["data"]
+ return fig, frame
return fig
diff --git a/tests/test_plotly_backend_layout.py b/tests/test_plotly_backend_layout.py
index a6fa1a8e..4d6f9471 100644
--- a/tests/test_plotly_backend_layout.py
+++ b/tests/test_plotly_backend_layout.py
@@ -50,12 +50,9 @@ def test_configure_formation_layout_rejects_too_many_rows():
@pytest.mark.essential
-def test_per_row_count_methods_are_gone():
- builder = plotutils.PlotlyPlotBuilder()
- assert not hasattr(builder, "_configure_formation_1_row")
- assert not hasattr(builder, "_configure_formation_2_rows")
- assert not hasattr(builder, "_configure_formation_3_rows")
- assert not hasattr(builder, "_configure_formation_4_rows")
+def test_plotly_plot_builder_is_gone():
+ assert not hasattr(plotutils, "PlotlyPlotBuilder")
+ assert not hasattr(plotutils, "SummaryPlotDataPreparer")
@pytest.mark.essential
diff --git a/tests/test_summary_prepare.py b/tests/test_summary_prepare.py
new file mode 100644
index 00000000..b72a6701
--- /dev/null
+++ b/tests/test_summary_prepare.py
@@ -0,0 +1,72 @@
+"""Tests for summary prepare → FigureSpec (#638)."""
+
+from __future__ import annotations
+
+import pytest
+
+from cellpy.plotting.context import from_source
+from cellpy.plotting.prepare.summary import prepare
+from cellpy.plotting import registry
+from cellpy.utils.plotutils import SummaryPlotConfig, SummaryPlotInfo, summary_plot
+
+
+@pytest.mark.essential
+def test_prepare_returns_frame_and_figure_spec(cell):
+ config = SummaryPlotConfig(y="capacities_gravimetric")
+ family = registry.get(config.y)
+ frame, spec = prepare(
+ from_source(cell), family, config, plot_info=SummaryPlotInfo(cell)
+ )
+ assert frame is not None
+ assert len(frame) > 0
+ assert "value" in frame.columns
+ assert "variable" in frame.columns
+ assert spec.extras.get("x") is not None
+ assert spec.extras.get("number_of_rows") == 1
+ assert "prepared_data_info" in spec.extras
+ assert "render" in spec.extras
+
+
+@pytest.mark.essential
+def test_prepare_ce_family_has_two_rows(cell):
+ config = SummaryPlotConfig(y="capacities_gravimetric_coulombic_efficiency")
+ family = registry.get(config.y)
+ frame, spec = prepare(
+ from_source(cell), family, config, plot_info=SummaryPlotInfo(cell)
+ )
+ assert spec.extras.get("number_of_rows") == 2
+ assert "row" in frame.columns
+ assert frame["row"].nunique() == 2
+ render = spec.extras["render"]
+ assert render.get("show_formation") is True
+ assert render.get("formation_layout") is not None
+
+
+@pytest.mark.essential
+def test_return_data_frame_shape(cell):
+ fig, data = summary_plot(
+ cell,
+ y="capacities_gravimetric",
+ return_data=True,
+ interactive=False,
+ show_formation=False,
+ )
+ assert fig is not None
+ assert "value" in data.columns
+ assert "variable" in data.columns
+ # cycle column is schema-bound (legacy cycle_index or native cycle_num)
+ cycle_cols = {"cycle_index", "cycle_num"}
+ assert cycle_cols.intersection(data.columns)
+
+
+@pytest.mark.essential
+def test_plotly_path_uses_backend(cell):
+ pytest.importorskip("plotly")
+ fig = summary_plot(
+ cell,
+ y="capacities_gravimetric",
+ interactive=True,
+ show_formation=True,
+ )
+ assert fig is not None
+ assert hasattr(fig, "to_plotly_json")