diff --git a/.issueflows/03-solved-issues/issue646_original.md b/.issueflows/03-solved-issues/issue646_original.md
new file mode 100644
index 00000000..73b509d1
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue646_original.md
@@ -0,0 +1,25 @@
+# Issue #646: Port cycles_plot to prepare→spec→render
+
+Source: https://github.com/jepegit/cellpy/issues/646
+
+## Original issue text
+
+## Context
+
+Part of epic #567 (Stage 2 — Other plot families on the same skeleton). Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md`.
+
+## Scope
+
+Add `prepare/curves.py` (voltage–capacity; prefer `cellpycore.curves` output, with fallback to `c.get_cap` if needed — same trick as the validation notebooks). Route `cycles_plot` through registry/spec/backends. Collapse `x_range`/`y_range` vs `xlim`/`ylim` to one spelling; keep the other as `warn_once` aliases in `DEPRECATIONS.md`.
+
+## Acceptance
+
+- `cycles_plot` oracle cases green both backends.
+- Deprecated range kwargs warn and behave identically.
+- No private layout fork left inside `cycles_plot`.
+
+## Depends on
+
+#639
+
+Part of epic #567.
diff --git a/.issueflows/03-solved-issues/issue646_plan.md b/.issueflows/03-solved-issues/issue646_plan.md
new file mode 100644
index 00000000..8dcfdc6f
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue646_plan.md
@@ -0,0 +1,102 @@
+# Issue #646 — Plan: port `cycles_plot` to prepare→spec→render
+
+## Goal
+
+Route public `cycles_plot` through **context → registry → prepare → backend.render**: add `cellpy/plotting/prepare/curves.py` (voltage–capacity frame + `FigureSpec`), register a cycles family, move the private plotly/matplotlib layout forks into the shared backends, and collapse dual range kwargs to one spelling with `warn_once` aliases. Oracle green both backends; no private layout left inside `cycles_plot`.
+
+## Constraints
+
+- Plan of record: [`architecture-plan/cellpy2-plotting-redesign-plan.md`](../../../architecture-plan/cellpy2-plotting-redesign-plan.md) Phase 3 / §1.3 (`x_range`/`y_range` vs `xlim`/`ylim`); epic [`.issueflows/05-epics/epic567_plan.md`](../05-epics/epic567_plan.md) Stage 2 issue 1.
+- Depends on #639 (merged): `get_backend("plotly"|"matplotlib")`, `Backend.render(frame, spec)`, `warn_once` + `DEPRECATIONS.md` pattern for `interactive=`.
+- Design notes: [`plotting-prepare.md`](../04-designs-and-guides/plotting-prepare.md), [`plotting-backends.md`](../04-designs-and-guides/plotting-backends.md), [`plotting-registry.md`](../04-designs-and-guides/plotting-registry.md).
+- Oracle gate: `tests/test_figure_specs.py` `cycles_plot[plotly|matplotlib]` green **without** intentional `figure_specs.json` regen unless a proven structural change is required.
+- Scope is **`cycles_plot` only** — do not port `raw_plot` / `cycle_info_plot` (#647) or ICA/DVA (#648).
+- Preserve user-facing defaults where possible (`return_data`, `return_figure` / `fig.show()` behaviour, formation split, capacity mode / interpolation knobs).
+- Not yolo — dual kwargs + curves adapter + second family in backends.
+
+### Prior art
+
+- `cycles_plot` + `CyclesPlotterConfig` + `_cycles_plotter_plotly` / `_cycles_plotter_matplotlib` — `cellpy/utils/plotutils.py` (~2070–2550). Today: `c.get_cap(...)` → sort → formation/rest split → private layout forks; `x_range`/`y_range` overwrite `xlim`/`ylim`; `interactive=` bool (not `backend=` yet).
+- `summary_plot` orchestration (#638/#639) — `cellpy/utils/plotutils.py`: resolve `backend=` / deprecated `interactive=` → `from_source` → `prepare.summary.prepare` → `get_backend(...).render`. **Mirror this shape.**
+- `prepare/summary.py` — contract `prepare(ctx, family, config) -> (frame, FigureSpec)`; render knobs in `spec.extras`.
+- `PlotlyBackend` / `MatplotlibBackend` — currently **summary-only** `render` (formation facet / `sns.relplot`). Cycles need a **second render branch**, not the summary formation engine.
+- Registry (`PlotFamily`) — summary `y=` names today; cycles is a single fixed family (no `y=` selector). Still register for `families()` / prepare routing / Stage-3 collectors reuse.
+- Curve source: public `CellpyCell.get_cap` → `cellpy.readers.capacity_curves.get_cap` (native `CurveCols` names). `cellpycore.curves.get_cap_curve` exists on the pinned core; issue text prefers it with `c.get_cap` fallback (architecture risk table).
+- Labels: `_get_capacity_unit` + `with_cellpy_unit("Voltage", ...)` in plotutils; prefer `units_label` / plotting.labels where already used by summary prepare.
+- Deprecations: `cellpy._deprecation.warn_once` + `DEPRECATIONS.md` (summary already registered `summary_plot(interactive=...)`).
+- Oracle: `tests/figure_spec_support.py` still passes `interactive=True/False` for cycles cases.
+- Toolbox / graphify: nothing relevant (`00-tools/` scanners unrelated; no `graphify-out/`).
+
+## Approach
+
+1. **Register a cycles family** in `cellpy/plotting/registry.py`
+ - Name: `"cycles"` (description: voltage vs capacity by cycle).
+ - `column_builder` can return the curve column ids used in the tidy frame (`capacity`, CurveCols `potential` / `cycle_num`) or a no-op list — cycles is not selected via `y=`.
+ - `supports_formation=True`.
+ - Do not break summary `families()` consumers: oracle summary menu stays derived from summary names only (or filter by a small `extras["menu"]` / keep cycles out of `SUMMARY_FAMILIES` — today menu is hardcoded in `figure_spec_support`, so registering `"cycles"` is fine).
+
+2. **`cellpy/plotting/prepare/curves.py`**
+ - Public: `prepare(ctx, family, config) -> tuple[pd.DataFrame, FigureSpec]`.
+ - **Curve load (one seam):** `_load_curve_frame(ctx, cycles, **get_cap_kwargs)`
+ - Prefer `cellpycore.curves.get_cap_curve` when the cell exposes core-ready data/schema **and** the resulting pandas frame matches today's column contract (`capacity`, `potential`, `cycle_num`, `direction`, …).
+ - Else fall back to `ctx.cell.get_cap(...)` (current path — oracle-stable).
+ - Keep the same kwargs currently passed (`method`, `interpolated`, `label_cycle_number`, `categorical_column`, `number_of_points`, `insert_nan`, `mode`, `cycle_mode`, `inter_cycle_shift`).
+ - Sort by cycle/direction; split formation vs rest; compute capacity unit + title strings.
+ - Emit `FigureSpec`: single panel, `x_axis`/`y_axis` labels + ranges from resolved `x_range`/`y_range`, `supports_formation` from config, title; stash cycles-specific render knobs in `extras` (`kind="cycles"`, form/rest frames or markers, colormap / colorbar flags, figsize/width/height, seaborn style knobs, plotly template, `n_form_cycles` / `n_rest_cycles`, etc.). Prefer one tidy `frame` (full `df`) plus selectors/extras rather than forcing backends to re-fetch.
+
+3. **Backend cycles branch**
+ - In `PlotlyBackend.render` / `MatplotlibBackend.render`: if `spec.extras.get("kind") == "cycles"` (or equivalent), run the ported `_cycles_plotter_*` logic; else keep today's summary path.
+ - Delete `_cycles_plotter_plotly` / `_cycles_plotter_matplotlib` (and ideally `CyclesPlotterConfig` if fully absorbed into prepare config/`FigureSpec`) from the live `cycles_plot` path — **no private layout fork left in `cycles_plot`**.
+ - Mechanical port first (oracle parity); do not redesign colorbar / px.line vs scatter heuristics in this issue.
+
+4. **Thin public `cycles_plot`**
+ - Keep the function in `cellpy.utils.plotutils` (permanent re-export home).
+ - Add `backend: Optional[str] = None`; change `interactive` default to `None` (same resolution order as `summary_plot`: warn on explicit `interactive=`, honour `backend=` if set, default `"plotly"`).
+ - **Range kwargs:** canonical spelling = `x_range` / `y_range` (align with `summary_plot` + architecture deprecation table). `xlim` / `ylim` become `warn_once` aliases → map into `x_range`/`y_range` when the canonical args are unset; if both set, prefer canonical after warning. Register in `_deprecation` seed + regen `DEPRECATIONS.md` (removal 2.1).
+ - Orchestration: build config → resolve backend + ranges → `from_source(c)` → `registry.get("cycles")` → `prepare.curves.prepare` → attach live cell/config on extras if mpl still needs them → `get_backend(...).render` → preserve `return_data` / `return_figure` / `fig.show()` semantics.
+
+5. **Tests / oracle harness**
+ - Point `cycles_plot[*]` cases at `backend=` (drop `interactive=` to avoid warning noise).
+ - Focused tests: prepare returns `(frame, FigureSpec)` with `kind=cycles`; `xlim`/`ylim` warn once and match `x_range`/`y_range`; `interactive=` warn+map; private plotter helpers gone / not imported by `cycles_plot`; oracle green.
+ - Prefer **no** `figure_specs.json` regen.
+
+6. **Design notes**
+ - Extend [`plotting-prepare.md`](../04-designs-and-guides/plotting-prepare.md) (curves prepare) and [`plotting-backends.md`](../04-designs-and-guides/plotting-backends.md) (cycles branch / `kind`). Brief registry note that non-summary families exist.
+
+## Files to touch
+
+| Path | Change |
+|---|---|
+| `cellpy/plotting/prepare/curves.py` | **new** — curve load + formation split + `FigureSpec` |
+| `cellpy/plotting/prepare/__init__.py` | export / note |
+| `cellpy/plotting/registry.py` | register `"cycles"` family |
+| `cellpy/plotting/backends/plotly.py` | cycles branch in `render` (port plotly plotter) |
+| `cellpy/plotting/backends/mpl.py` | cycles branch in `render` (port matplotlib plotter) |
+| `cellpy/utils/plotutils.py` | thin `cycles_plot`; delete private plotters / absorb config |
+| `cellpy/_deprecation.py` | seed `cycles_plot(xlim/ylim=...)` (+ `interactive=` if added) |
+| `DEPRECATIONS.md` | regen |
+| `tests/figure_spec_support.py` | cycles cases → `backend=` |
+| `tests/test_cycles_prepare.py` (name flexible) | **new** — prepare / deprecations / no private fork |
+| `tests/test_figure_specs.py` | run; adjust only if describe asymmetry notes need wording |
+| `.issueflows/04-designs-and-guides/plotting-*.md` | document curves prepare + backend kind |
+
+## Test strategy
+
+```bash
+uv sync --extra batch
+MPLBACKEND=Agg uv run pytest tests/test_cycles_prepare.py tests/test_figure_specs.py -q
+MPLBACKEND=Agg uv run pytest -m essential --ignore=tests/test_arbin_variants_two_stage.py
+```
+
+Parity focus: default oracle cell × both backends; formation on/off; range aliases; `return_data` frame columns match pre-port `get_cap` shape on the oracle cell.
+
+## Open questions
+
+1. **Canonical range spelling** — **Recommend:** keep `x_range`/`y_range`; deprecate `xlim`/`ylim` (matches `summary_plot` + architecture plan). Alternative: keep `xlim`/`ylim` because docstring/examples emphasize them — rejected (inconsistent API).
+2. **Also flip `backend=` / deprecate `interactive=` on `cycles_plot` in this PR?** — **Recommend:** yes (same pattern as #639; oracle harness already needs updating; epic backend policy is global). Alternative: leave `interactive=` only until a later cleanup — leaves Stage-2 API half-migrated.
+3. **`cellpycore.curves` vs `c.get_cap` for this PR** — **Recommend:** implement the load seam; use `c.get_cap` as the default working path so the oracle does not drift; attempt core `get_cap_curve` only when it can be proven column-/row-equivalent (or behind a small internal helper that falls back immediately on mismatch/ImportError). Alternative: hard-require core first — higher risk of snapshot churn with little user benefit.
+4. **How backends dispatch** — **Recommend:** `spec.extras["kind"] == "cycles"` branch inside existing `render` methods (mechanical port). Alternative: separate `CyclesPlotlyBackend` class — rejected (breaks `get_backend` two-name API).
+
+## Scope check
+
+One Stage-2 slice: prepare/curves + registry family + backend cycles branch + thin public API + deprecations. Fits a single PR. Follow-ups already published: #647 (`raw_plot` / `cycle_info_plot`), #648 (ICA/DVA).
diff --git a/.issueflows/03-solved-issues/issue646_status.md b/.issueflows/03-solved-issues/issue646_status.md
new file mode 100644
index 00000000..990fa03a
--- /dev/null
+++ b/.issueflows/03-solved-issues/issue646_status.md
@@ -0,0 +1,19 @@
+# Issue #646 — Status
+
+- [x] Done
+
+## What's done
+
+- Plan accepted (`issue646_plan.md`).
+- Registered `"cycles"` family with `entry_point="cycles_plot"`; summary oracle filters via `families(entry_point="summary_plot")`.
+- Added `cellpy/plotting/prepare/curves.py` (`CyclesPrepareConfig` + `prepare` → frame/`FigureSpec`, `kind="cycles"`).
+- Ported cycles render into `PlotlyBackend` / `MatplotlibBackend` (`_render_cycles`).
+- Thinned `cycles_plot`: `backend=`, `warn_once` for `interactive=` / `xlim`/`ylim`; deleted `CyclesPlotterConfig` + private plotters.
+- Tests: `tests/test_cycles_prepare.py`; oracle harness on `backend=`; design notes updated; `DEPRECATIONS.md` regenerated.
+- `MPLBACKEND=Agg uv run pytest tests/test_cycles_prepare.py tests/test_plotting_registry.py tests/test_figure_specs.py tests/test_mpl_backend.py` — 72 passed.
+- `MPLBACKEND=Agg uv run pytest -m essential --ignore=tests/test_arbin_variants_two_stage.py` — 562 passed, 1 skipped.
+- `HISTORY.md` Unreleased bullet added; issue group archived to `03-solved-issues/`.
+
+## Remaining work
+
+- None (merge + `/iflow-cleanup` after PR lands).
diff --git a/.issueflows/04-designs-and-guides/plotting-backends.md b/.issueflows/04-designs-and-guides/plotting-backends.md
index dcb179e5..4fade8a7 100644
--- a/.issueflows/04-designs-and-guides/plotting-backends.md
+++ b/.issueflows/04-designs-and-guides/plotting-backends.md
@@ -3,7 +3,8 @@
## Context
Epic #567 Stage 1 needs one layout engine and prepare→spec→render for
-`summary_plot`, with two public backends (`plotly` | `matplotlib`).
+`summary_plot`, with two public backends (`plotly` | `matplotlib`). Stage 2
+(#646) adds a second render branch for `cycles_plot`.
## Decision
@@ -15,10 +16,14 @@ Epic #567 Stage 1 needs one layout engine and prepare→spec→render for
- **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).
+- **Public switch:** `summary_plot(..., backend="plotly"|"matplotlib")` and
+ `cycles_plot(..., backend=...)`. `interactive=` is a `warn_once` alias
+ (removal 2.1) on both.
+- **Family dispatch (#646):** when `spec.extras.get("kind") == "cycles"`,
+ both backends take the voltage–capacity render path (ported from the old
+ private `_cycles_plotter_*` helpers). Otherwise they keep the summary path.
## Links
-- Issues #639, #638, #637, #636; epic #567
+- Issues #646, #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 3c5a72a9..5e98f56b 100644
--- a/.issueflows/04-designs-and-guides/plotting-prepare.md
+++ b/.issueflows/04-designs-and-guides/plotting-prepare.md
@@ -1,14 +1,15 @@
-# Plotting prepare (summary)
+# Plotting prepare (summary + curves)
## Context
`SummaryPlotDataPreparer` lived in `cellpy/utils/plotutils.py` and fed both
plotly and seaborn builders. Epic #567 Stage 1 needs prepare → `FigureSpec` →
-backend.render as the only summary path.
+backend.render as the only summary path. Stage 2 (#646) extends the same
+contract to `cycles_plot`.
## Decision
-- **Prepare lives in** `cellpy/plotting/prepare/summary.py`.
+- **Summary prepare** lives in `cellpy/plotting/prepare/summary.py`.
`prepare(ctx, family, config) -> (frame, FigureSpec)`.
- **`SummaryPlotDataPreparer`** moved there (implementation detail); deleted
from `plotutils`.
@@ -16,12 +17,17 @@ backend.render as the only summary path.
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.
+- **Curves prepare** lives in `cellpy/plotting/prepare/curves.py` (#646).
+ Same `(frame, FigureSpec)` contract; `spec.extras["kind"] == "cycles"` plus
+ form/rest frames and styling knobs. Curve load seam defaults to
+ `c.get_cap` (oracle-stable); `cellpycore.curves` preferred path can land
+ later behind `_load_curve_frame`.
- **`CellContext`** in `cellpy/plotting/context.py` is the thin cell adapter;
BatchContext waits for collectors rebase.
-- Public `summary_plot` stays in `plotutils` and orchestrates
+- Public `summary_plot` / `cycles_plot` stay in `plotutils` and orchestrate
context → registry → prepare → `get_backend(backend).render`.
## Links
-- Issues #639, #638; epic #567
+- Issues #646, #639, #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 d2215fcb..ae4de9fc 100644
--- a/.issueflows/04-designs-and-guides/plotting-registry.md
+++ b/.issueflows/04-designs-and-guides/plotting-registry.md
@@ -18,7 +18,10 @@ moves selection into `cellpy.plotting.registry`.
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.
+ `families(entry_point="summary_plot")` so the snapshot menu cannot drift
+ from the runtime menu. Non-summary families (e.g. `"cycles"` for
+ `cycles_plot`, #646) register with `extras={"entry_point": "cycles_plot"}`
+ and are excluded from the summary oracle.
## Alternatives considered
diff --git a/.issueflows/05-epics/epic567_plan.md b/.issueflows/05-epics/epic567_plan.md
index fe5cdb3f..6d3ef7e3 100644
--- a/.issueflows/05-epics/epic567_plan.md
+++ b/.issueflows/05-epics/epic567_plan.md
@@ -147,6 +147,7 @@ figure oracle. No collectors/batch work yet.
identically; no private layout fork left inside `cycles_plot`.
- Depends on: #639
- yolo: no — dual kwargs + curves adapter choices.
+- Published: #646
### Issue: Port `raw_plot` and `cycle_info_plot`
@@ -158,9 +159,10 @@ figure oracle. No collectors/batch work yet.
for both functions × both backends green; no hand-composed unit f-strings
remain in these two code paths; header lookups use the public schema/header
helpers (no new hard-coded legacy names).
-- Depends on: stage 2 issue 1
+- Depends on: #646
- yolo: no — two families, header/unit sensitivity (Phase 0 already found bugs
here).
+- Published: #647
### Issue: Add `ica_plot` / `dva_plot` families on the new pipeline
@@ -171,8 +173,9 @@ figure oracle. No collectors/batch work yet.
re-exports). Add corresponding cases to the figure-spec snapshot. Acceptance:
new oracle cases committed and green; plots honour cell-centric `direction`;
no dependency on deleted `Converter` / wide-frame helpers.
-- Depends on: stage 2 issue 2
+- Depends on: #647
- yolo: no — new public surface; needs snapshot design choices.
+- Published: #648
## Stage 3 — Collectors drawing half, Batch.plot, retire batch_plotters
diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md
index 57385f2c..aa8f2c78 100644
--- a/DEPRECATIONS.md
+++ b/DEPRECATIONS.md
@@ -8,6 +8,9 @@ uv run python -m cellpy._deprecation
| Name | Replacement | Introduced | Removal |
| --- | --- | --- | --- |
+| `cycles_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 |
+| `cycles_plot(xlim=...)` | `cycles_plot(x_range=...)` | 2.0 | 2.1 |
+| `cycles_plot(ylim=...)` | `cycles_plot(y_range=...)` | 2.0 | 2.1 |
| `ica.Converter` | `cellpy.ica.transform_half_cycle with IcaOptions` | 2.0 | 2.1 |
| `ica.dqdv(cycle=...)` | `cellpy.ica.dqdv(cycles=...)` | 2.0 | 2.1 |
| `ica.dqdv(label_direction=...)` | `the direction column, which the specced frame always carries` | 2.0 | 2.1 |
diff --git a/HISTORY.md b/HISTORY.md
index abdd3d71..1631e803 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -2,6 +2,8 @@
## [Unreleased]
+* Port cycles_plot to prepare→spec→render (#646).
+
* Matplotlib backend; retire SeabornPlotBuilder; unify backend= (#639).
* Port summary prepare path and flip summary_plot to prepare→spec→render (#638).
diff --git a/cellpy/_deprecation.py b/cellpy/_deprecation.py
index 6495caba..7defa239 100644
--- a/cellpy/_deprecation.py
+++ b/cellpy/_deprecation.py
@@ -161,6 +161,22 @@ def _seed_known_deprecations() -> None:
'backend="plotly"|"matplotlib"',
removal="2.1",
)
+ # Stage 2 (#646): cycles_plot backend= + range spelling.
+ _register(
+ "cycles_plot(interactive=...)",
+ 'backend="plotly"|"matplotlib"',
+ removal="2.1",
+ )
+ _register(
+ "cycles_plot(xlim=...)",
+ "cycles_plot(x_range=...)",
+ removal="2.1",
+ )
+ _register(
+ "cycles_plot(ylim=...)",
+ "cycles_plot(y_range=...)",
+ removal="2.1",
+ )
if __name__ == "__main__":
diff --git a/cellpy/plotting/backends/mpl.py b/cellpy/plotting/backends/mpl.py
index c5d56d87..21a825d1 100644
--- a/cellpy/plotting/backends/mpl.py
+++ b/cellpy/plotting/backends/mpl.py
@@ -63,6 +63,9 @@ def __init__(self) -> None:
def render(self, frame: Any, spec: FigureSpec) -> Any:
"""Render a tidy frame according to *spec* (matplotlib Figure)."""
extras = dict(spec.extras or {})
+ if extras.get("kind") == "cycles":
+ return self._render_cycles(frame, spec)
+
config = extras.get("config")
c = extras.get("cell")
if config is None or c is None:
@@ -914,4 +917,144 @@ def _convert_legend_labels(self, sns_fig):
le.set_text(name)
sns_fig.legend.set_title(None)
+ def _render_cycles(self, frame: Any, spec: FigureSpec) -> Any:
+ """Render voltage–capacity cycles figures (#646).
+
+ Mechanical port of ``plotutils._cycles_plotter_matplotlib``.
+ """
+ from cellpycore.config import CurveCols
+
+ import matplotlib
+ import matplotlib.pyplot as plt
+ from matplotlib.colors import ListedColormap, Normalize
+
+ from cellpy.units import with_cellpy_unit
+
+ ccols = CurveCols()
+ extras = dict(spec.extras or {})
+ c = extras.get("cell")
+ if c is None:
+ raise ValueError(
+ "FigureSpec.extras['cell'] is required for cycles MatplotlibBackend.render"
+ )
+
+ form_cycles = extras.get("form_cycles")
+ rest_cycles = extras.get("rest_cycles")
+ if form_cycles is None or rest_cycles is None:
+ raise ValueError(
+ "FigureSpec.extras must include 'form_cycles' and 'rest_cycles'"
+ )
+
+ if _seaborn_available():
+ import seaborn as sns
+
+ seaborn_style_dict = extras.get("seaborn_style_dict") or {
+ "axes.facecolor": extras.get("seaborn_facecolor", "#EAEAF2"),
+ "axes.edgecolor": extras.get("seaborn_edgecolor", "black"),
+ }
+ sns.set_style(extras.get("seaborn_style", "dark"), seaborn_style_dict)
+ sns.set_palette(extras.get("seaborn_palette", "deep"))
+ sns.set_context(extras.get("seaborn_context", "notebook"))
+
+ figsize = extras.get("figsize") or (6, 4)
+ fig, ax = plt.subplots(1, 1, figsize=figsize)
+ fig_width, _fig_height = figsize
+ show_formation = bool(extras.get("show_formation", True))
+ n_form_cycles = extras.get("n_form_cycles") or 0
+ cbar_aspect = extras.get("cbar_aspect", 30)
+ formation_colormap = extras.get("formation_colormap") or "autumn"
+ colormap = extras.get("colormap") or "Blues_r"
+ cut_colorbar = bool(extras.get("cut_colorbar", True))
+ capacity_unit = extras.get("capacity_unit") or "-"
+ capacity_label = extras.get("capacity_label") or f"Capacity ({capacity_unit})"
+ voltage_label = extras.get("voltage_label") or with_cellpy_unit(
+ "Voltage", "voltage", units=c.cellpy_units
+ )
+ x_range = extras.get("x_range")
+ y_range = extras.get("y_range")
+
+ if not form_cycles.empty and show_formation:
+ if fig_width < 6:
+ logging.critical(
+ "Warning: try setting the figsize to (6, 4) or larger for better visualization"
+ )
+ if fig_width > 8:
+ logging.critical(
+ "Warning: try setting the figsize to (8, 4) or smaller for better visualization"
+ )
+ min_cycle = form_cycles[ccols.cycle_num].min()
+ max_cycle = form_cycles[ccols.cycle_num].max()
+ norm_formation = Normalize(vmin=min_cycle, vmax=max_cycle)
+ cycle_sequence = np.arange(min_cycle, max_cycle + 1, 1)
+ shrink = min(1.0, (1 / 8) * n_form_cycles)
+ c_m_formation = ListedColormap(
+ plt.get_cmap(formation_colormap, 2 * len(cycle_sequence))(
+ cycle_sequence
+ )
+ )
+ s_m_formation = matplotlib.cm.ScalarMappable(
+ cmap=c_m_formation, norm=norm_formation
+ )
+ for name, group in form_cycles.groupby(ccols.cycle_num):
+ ax.plot(
+ group["capacity"],
+ group[ccols.potential],
+ lw=2,
+ color=s_m_formation.to_rgba(name),
+ label=f"Cycle {name}",
+ )
+ cbar_formation = fig.colorbar(
+ s_m_formation,
+ ax=ax,
+ ticks=np.arange(min_cycle, max_cycle + 1, 1),
+ shrink=shrink,
+ aspect=cbar_aspect * shrink,
+ location="right",
+ anchor=(0.0, 0.0),
+ )
+ cbar_formation.set_label("Form. Cycle", rotation=270, labelpad=12)
+
+ norm = Normalize(
+ vmin=rest_cycles[ccols.cycle_num].min(),
+ vmax=rest_cycles[ccols.cycle_num].max(),
+ )
+ if cut_colorbar:
+ cycle_sequence = np.arange(
+ rest_cycles[ccols.cycle_num].min(),
+ rest_cycles[ccols.cycle_num].max() + 1,
+ 1,
+ )
+ n = int(np.round(1.2 * rest_cycles[ccols.cycle_num].max()))
+ c_m = ListedColormap(plt.get_cmap(colormap, n)(cycle_sequence))
+ else:
+ c_m = plt.get_cmap(colormap)
+
+ s_m = matplotlib.cm.ScalarMappable(cmap=c_m, norm=norm)
+ for name, group in rest_cycles.groupby(ccols.cycle_num):
+ ax.plot(
+ group["capacity"],
+ group[ccols.potential],
+ lw=1,
+ color=s_m.to_rgba(name),
+ label=f"Cycle {name}",
+ )
+ cbar = fig.colorbar(
+ s_m,
+ ax=ax,
+ label="Cycle",
+ aspect=cbar_aspect,
+ location="right",
+ )
+ cbar.set_label("Cycle", rotation=270, labelpad=12)
+
+ ax.set_xlabel(capacity_label)
+ ax.set_ylabel(voltage_label)
+ ax.set_title(spec.title, loc="left", wrap=True)
+ fig.tight_layout()
+ if x_range:
+ ax.set_xlim(x_range)
+ if y_range:
+ ax.set_ylim(y_range)
+ return fig
+
diff --git a/cellpy/plotting/backends/plotly.py b/cellpy/plotting/backends/plotly.py
index 986a60f3..b32cc841 100644
--- a/cellpy/plotting/backends/plotly.py
+++ b/cellpy/plotting/backends/plotly.py
@@ -295,9 +295,12 @@ def __init__(self) -> None:
self.col_id = "cycle_type"
def render(self, frame: Any, spec: FigureSpec) -> Any:
+ extras = dict(spec.extras or {})
+ if extras.get("kind") == "cycles":
+ return self._render_cycles(frame, spec)
+
import plotly.express as px
- extras = dict(spec.extras or {})
render = dict(extras.get("render") or {})
prepared = dict(extras.get("prepared_data_info") or {})
@@ -559,3 +562,152 @@ def _convert_legend_labels(fig: Any) -> None:
statements.append(statement)
hover_template = "
".join(statements)
trace.update(name=name, hovertemplate=hover_template)
+
+ def _render_cycles(self, frame: Any, spec: FigureSpec) -> Any:
+ """Render voltage–capacity cycles figures (#646).
+
+ Mechanical port of ``plotutils._cycles_plotter_plotly``.
+ """
+ from cellpycore.config import CurveCols
+
+ import plotly.express as px
+ import plotly.graph_objects as go
+
+ from cellpy.units import with_cellpy_unit
+ from cellpy.utils.plotutils import set_plotly_template
+
+ ccols = CurveCols()
+ extras = dict(spec.extras or {})
+ c = extras.get("cell")
+ if c is None:
+ raise ValueError(
+ "FigureSpec.extras['cell'] is required for cycles PlotlyBackend.render"
+ )
+
+ form_cycles = extras.get("form_cycles")
+ rest_cycles = extras.get("rest_cycles")
+ if form_cycles is None or rest_cycles is None:
+ raise ValueError(
+ "FigureSpec.extras must include 'form_cycles' and 'rest_cycles'"
+ )
+
+ set_plotly_template(extras.get("plotly_template"))
+ kwargs = dict(extras.get("additional_kwargs") or {})
+ plotly_max_individual_traces_for_lines = kwargs.pop(
+ "plotly_max_individual_traces_for_lines", 8
+ )
+
+ colormap = extras.get("colormap") or "Blues_r"
+ color_scales = px.colors.named_colorscales()
+ if colormap not in color_scales:
+ colormap = "Blues_r"
+
+ capacity_unit = extras.get("capacity_unit") or "-"
+ capacity_label = extras.get("capacity_label") or f"Capacity ({capacity_unit})"
+ voltage_label = extras.get("voltage_label") or with_cellpy_unit(
+ "Voltage", "voltage", units=c.cellpy_units
+ )
+ fig_title = spec.title
+ n_rest_cycles = extras.get("n_rest_cycles")
+ cut_colorbar = bool(extras.get("cut_colorbar", True))
+ force_colorbar = bool(extras.get("force_colorbar", False))
+ force_nonbar = bool(extras.get("force_nonbar", False))
+ show_formation = bool(extras.get("show_formation", True))
+ marker_size = extras.get("marker_size", 5)
+ formation_line_color = extras.get(
+ "formation_line_color", "rgba(152, 0, 0, .8)"
+ )
+ width = extras.get("width", 800)
+ height = extras.get("height", 600)
+ x_range = extras.get("x_range")
+ y_range = extras.get("y_range")
+
+ if cut_colorbar:
+ range_color = [
+ frame[ccols.cycle_num].min(),
+ 1.2 * frame[ccols.cycle_num].max(),
+ ]
+ else:
+ range_color = [
+ frame[ccols.cycle_num].min(),
+ frame[ccols.cycle_num].max(),
+ ]
+
+ if (
+ n_rest_cycles is not None
+ and n_rest_cycles < plotly_max_individual_traces_for_lines
+ and not force_colorbar
+ ) or force_nonbar:
+ logger.info("using px.line for non-formation cycles")
+ show_formation_legend = True
+ cmap = px.colors.sample_colorscale(
+ colorscale=colormap,
+ samplepoints=n_rest_cycles,
+ low=0.0,
+ high=0.8,
+ colortype="rgb",
+ )
+ fig = px.line(
+ rest_cycles,
+ x="capacity",
+ y=ccols.potential,
+ color=ccols.cycle_num,
+ title=fig_title,
+ labels={
+ "capacity": capacity_label,
+ ccols.potential: voltage_label,
+ },
+ color_discrete_sequence=cmap,
+ )
+ else:
+ logger.info("using px.scatter for non-formation cycles")
+ show_formation_legend = False
+ fig = px.scatter(
+ rest_cycles,
+ x="capacity",
+ y=ccols.potential,
+ title=fig_title,
+ color=ccols.cycle_num,
+ labels={
+ "capacity": capacity_label,
+ ccols.potential: voltage_label,
+ },
+ color_continuous_scale=colormap,
+ range_color=range_color,
+ )
+ fig.update_traces(mode="lines+markers", line_color="white", line_width=1)
+
+ if not form_cycles.empty and show_formation:
+ for name, group in form_cycles.groupby(ccols.cycle_num):
+ logger.info("using go.Scatter for formation cycle(s) %s", name)
+ fig.add_trace(
+ go.Scatter(
+ x=group["capacity"],
+ y=group[ccols.potential],
+ name=f"{name} (f.c.)",
+ hovertemplate=(
+ f"Formation Cycle {name}
Capacity: %{{x}}
Voltage: %{{y}}"
+ ),
+ mode="lines",
+ marker=dict(color=formation_line_color),
+ showlegend=show_formation_legend,
+ legendrank=1,
+ legendgroup="formation",
+ )
+ )
+
+ fig.update_traces(marker=dict(size=marker_size))
+ if x_range:
+ fig.update_xaxes(range=x_range)
+ if y_range:
+ fig.update_yaxes(range=y_range)
+
+ plotly_xaxes_kwargs = kwargs.pop("plotly_xaxes_kwargs", {})
+ plotly_yaxes_kwargs = kwargs.pop("plotly_yaxes_kwargs", {})
+ if plotly_xaxes_kwargs:
+ fig.update_xaxes(**plotly_xaxes_kwargs)
+ if plotly_yaxes_kwargs:
+ fig.update_yaxes(**plotly_yaxes_kwargs)
+ plotly_layout_kwargs = kwargs.pop("plotly_layout_kwargs", {})
+ fig.update_layout(height=height, width=width, **plotly_layout_kwargs)
+ return fig
diff --git a/cellpy/plotting/prepare/__init__.py b/cellpy/plotting/prepare/__init__.py
index c55c60e6..5e33c4ff 100644
--- a/cellpy/plotting/prepare/__init__.py
+++ b/cellpy/plotting/prepare/__init__.py
@@ -1,7 +1,8 @@
-"""Prepare stages for the prepare → spec → render pipeline (#638)."""
+"""Prepare stages for the prepare → spec → render pipeline (#638 / #646)."""
from __future__ import annotations
+from cellpy.plotting.prepare.curves import prepare as prepare_curves
from cellpy.plotting.prepare.summary import prepare as prepare_summary
-__all__ = ["prepare_summary"]
+__all__ = ["prepare_curves", "prepare_summary"]
diff --git a/cellpy/plotting/prepare/curves.py b/cellpy/plotting/prepare/curves.py
new file mode 100644
index 00000000..12ecba91
--- /dev/null
+++ b/cellpy/plotting/prepare/curves.py
@@ -0,0 +1,213 @@
+"""Cycles-plot prepare path: voltage–capacity frame + FigureSpec (#646).
+
+Public ``cycles_plot`` calls :func:`prepare` then hands ``(frame, FigureSpec)``
+to a backend renderer (``spec.extras['kind'] == 'cycles'``).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+import pandas as pd
+from cellpycore.config import CurveCols
+
+from cellpy.exceptions import UnitsError
+from cellpy.plotting.spec import AxisSpec, FigureSpec, PanelSpec
+from cellpy.units import units_label, with_cellpy_unit
+
+_CCOLS = CurveCols()
+
+
+@dataclass
+class CyclesPrepareConfig:
+ """Input knobs for :func:`prepare` (mirrors public ``cycles_plot`` args)."""
+
+ cycles: Optional[Any] = None
+ inter_cycle_shift: bool = True
+ cycle_mode: Optional[str] = None
+ formation_cycles: int = 3
+ show_formation: bool = True
+ mode: str = "gravimetric"
+ method: str = "forth-and-forth"
+ interpolated: bool = True
+ number_of_points: int = 200
+ colormap: str = "Blues_r"
+ formation_colormap: str = "autumn"
+ cut_colorbar: bool = True
+ title: Optional[str] = None
+ figsize: tuple = (6, 4)
+ x_range: Optional[list] = None
+ y_range: Optional[list] = None
+ width: int = 800
+ height: int = 600
+ marker_size: int = 5
+ formation_line_color: str = "rgba(152, 0, 0, .8)"
+ force_colorbar: bool = False
+ force_nonbar: bool = False
+ plotly_template: Optional[str] = None
+ seaborn_palette: str = "deep"
+ seaborn_style: str = "dark"
+ seaborn_context: str = "notebook"
+ seaborn_facecolor: str = "#EAEAF2"
+ seaborn_edgecolor: str = "black"
+ seaborn_style_dict: Optional[dict] = None
+ cbar_aspect: int = 30
+ backend: str = "plotly"
+ additional_kwargs: dict = field(default_factory=dict)
+
+
+def _capacity_unit(c: Any, mode: str = "gravimetric") -> str:
+ 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 _load_curve_frame(ctx: Any, cycles: Any, **get_cap_kwargs: Any) -> pd.DataFrame:
+ """Load a tidy capacity–voltage frame.
+
+ Seam for a future ``cellpycore.curves.get_cap_curve`` preferred path
+ (architecture-plan risk table / epic #567). For #646 the working path is
+ ``c.get_cap``, which already returns native ``CurveCols`` frames and
+ matches the committed figure-spec oracle.
+ """
+ return ctx.cell.get_cap(cycles=cycles, **get_cap_kwargs)
+
+
+def _default_title(
+ c: Any,
+ *,
+ backend: str,
+ mode: str,
+ interpolated: bool,
+ number_of_points: int,
+) -> str:
+ use_plotly_markup = backend == "plotly"
+ _bold = "" if use_plotly_markup else "'"
+ _end_bold = "" if use_plotly_markup else "'"
+ _newline = "
" if use_plotly_markup else "\n"
+ _small = '' if use_plotly_markup else ""
+ _end_small = "" if use_plotly_markup else ""
+ top_title_line = f"Capacity plots for {_bold}{c.cell_name}{_end_bold}"
+ second_title_line = f"{_small} - {mode} mode"
+ if interpolated:
+ second_title_line = (
+ f"{second_title_line}, interpolated ({number_of_points} points){_end_small}"
+ )
+ else:
+ second_title_line = f"{second_title_line}{_end_small}"
+ return _newline.join([top_title_line, second_title_line])
+
+
+def prepare(
+ ctx: Any,
+ family: Any,
+ config: CyclesPrepareConfig,
+) -> tuple[pd.DataFrame, FigureSpec]:
+ """Prepare a voltage–capacity frame and :class:`FigureSpec` for ``cycles_plot``.
+
+ Args:
+ ctx: :class:`~cellpy.plotting.context.CellContext` (or compatible).
+ family: registered :class:`~cellpy.plotting.registry.PlotFamily` (``cycles``).
+ config: :class:`CyclesPrepareConfig` built by the public entry point.
+
+ Returns:
+ ``(frame, spec)`` where ``spec.extras['kind'] == 'cycles'``.
+ """
+ c = ctx.cell
+ cycles = config.cycles
+ if cycles is None:
+ cycles = c.get_cycle_numbers()
+
+ title = config.title
+ if title is None:
+ title = _default_title(
+ c,
+ backend=config.backend,
+ mode=config.mode,
+ interpolated=config.interpolated,
+ number_of_points=config.number_of_points,
+ )
+
+ get_cap_kwargs = dict(
+ method=config.method,
+ interpolated=config.interpolated,
+ label_cycle_number=True,
+ categorical_column=True,
+ number_of_points=config.number_of_points,
+ insert_nan=True,
+ mode=config.mode,
+ cycle_mode=config.cycle_mode,
+ inter_cycle_shift=config.inter_cycle_shift,
+ )
+ df = _load_curve_frame(ctx, cycles, **get_cap_kwargs)
+ df = df.sort_values(by=[_CCOLS.cycle_num, "direction"])
+
+ selector = df[_CCOLS.cycle_num] <= config.formation_cycles
+ form_cycles = df.loc[selector, :]
+ rest_cycles = df.loc[~selector, :]
+ n_form_cycles = len(form_cycles[_CCOLS.cycle_num].unique())
+ n_rest_cycles = len(rest_cycles[_CCOLS.cycle_num].unique())
+
+ capacity_unit = _capacity_unit(c, mode=config.mode)
+ voltage_label = with_cellpy_unit("Voltage", "voltage", units=c.cellpy_units)
+ capacity_label = f"Capacity ({capacity_unit})"
+
+ x_range = _range_tuple(config.x_range)
+ y_range = _range_tuple(config.y_range)
+
+ family_name = getattr(family, "name", "cycles")
+ spec = FigureSpec(
+ panels=(
+ PanelSpec(
+ columns=("capacity", _CCOLS.potential),
+ kind="line",
+ y_axis=AxisSpec(label=voltage_label, range=y_range),
+ ),
+ ),
+ x_axis=AxisSpec(label=capacity_label, range=x_range),
+ title=title,
+ supports_formation=bool(config.show_formation),
+ extras={
+ "kind": "cycles",
+ "family": family_name,
+ "form_cycles": form_cycles,
+ "rest_cycles": rest_cycles,
+ "capacity_unit": capacity_unit,
+ "voltage_label": voltage_label,
+ "capacity_label": capacity_label,
+ "plotly_template": config.plotly_template,
+ "colormap": config.colormap,
+ "formation_colormap": config.formation_colormap,
+ "cut_colorbar": config.cut_colorbar,
+ "cbar_aspect": config.cbar_aspect,
+ "figsize": config.figsize,
+ "force_colorbar": config.force_colorbar,
+ "force_nonbar": config.force_nonbar,
+ "n_rest_cycles": n_rest_cycles,
+ "n_form_cycles": n_form_cycles,
+ "show_formation": config.show_formation,
+ "width": config.width,
+ "height": config.height,
+ "marker_size": config.marker_size,
+ "formation_line_color": config.formation_line_color,
+ "x_range": list(config.x_range) if config.x_range is not None else None,
+ "y_range": list(config.y_range) if config.y_range is not None else None,
+ "seaborn_style": config.seaborn_style,
+ "seaborn_palette": config.seaborn_palette,
+ "seaborn_context": config.seaborn_context,
+ "seaborn_facecolor": config.seaborn_facecolor,
+ "seaborn_edgecolor": config.seaborn_edgecolor,
+ "seaborn_style_dict": config.seaborn_style_dict,
+ "additional_kwargs": dict(config.additional_kwargs or {}),
+ "cell": c,
+ },
+ )
+ return df, spec
diff --git a/cellpy/plotting/registry.py b/cellpy/plotting/registry.py
index b6f7ab28..c6dd7a97 100644
--- a/cellpy/plotting/registry.py
+++ b/cellpy/plotting/registry.py
@@ -1,10 +1,11 @@
-"""Named summary-plot families — the declarative replacement for the old
+"""Named plot families — the declarative replacement for the old
``SummaryPlotInfo._create_col_info`` column table (#636 / epic #567).
-Column names are header-bound (they depend on ``c.headers_summary``), so each
-family carries a small resolver rather than a frozen list of strings. Drawing
-still goes through today's builders; only selection / listing / extension live
-here.
+Column names for summary families are header-bound (they depend on
+``c.headers_summary``), so each family carries a small resolver rather than a
+frozen list of strings. Non-summary families (e.g. ``cycles`` for
+``cycles_plot``, #646) register with ``extras["entry_point"]`` so
+``families(entry_point=...)`` / the summary oracle stay scoped.
"""
from __future__ import annotations
@@ -63,14 +64,33 @@ def get(name: str) -> PlotFamily:
) from exc
-def families() -> list[tuple[str, str]]:
- """Return ``(name, description)`` for every registered family, in registration order."""
- return [(family.name, family.description) for family in _FAMILIES.values()]
+def _entry_point(family: PlotFamily) -> str:
+ """Public entry that owns this family (default: ``summary_plot``)."""
+ return (family.extras or {}).get("entry_point", "summary_plot")
-def iter_families() -> list[PlotFamily]:
- """Return registered families in registration order."""
- return list(_FAMILIES.values())
+def families(*, entry_point: Optional[str] = None) -> list[tuple[str, str]]:
+ """Return ``(name, description)`` for registered families, in registration order.
+
+ Pass ``entry_point="summary_plot"`` (or ``"cycles_plot"``, …) to restrict the
+ list to one public plot entry. ``None`` returns every registered family.
+ """
+ out: list[tuple[str, str]] = []
+ for family in _FAMILIES.values():
+ if entry_point is not None and _entry_point(family) != entry_point:
+ continue
+ out.append((family.name, family.description))
+ return out
+
+
+def iter_families(*, entry_point: Optional[str] = None) -> list[PlotFamily]:
+ """Return registered families in registration order.
+
+ See :func:`families` for the optional ``entry_point`` filter (#646).
+ """
+ if entry_point is None:
+ return list(_FAMILIES.values())
+ return [f for f in _FAMILIES.values() if _entry_point(f) == entry_point]
def _register_family(family: PlotFamily) -> None:
@@ -266,6 +286,14 @@ def _register_builtin_families() -> None:
mode="gravimetric",
transforms_builder=lambda hdr, norm: _retention_transforms(hdr, norm, "gravimetric"),
),
+ # Stage 2 (#646): voltage–capacity curves. Not a summary_plot(y=...) name.
+ PlotFamily(
+ name="cycles",
+ description="Voltage vs capacity by cycle",
+ column_builder=lambda hdr: ["capacity", "potential", "cycle_num"],
+ supports_formation=True,
+ extras={"entry_point": "cycles_plot", "kind": "cycles"},
+ ),
]
for family in builtins:
_register_family(family)
diff --git a/cellpy/utils/plotutils.py b/cellpy/utils/plotutils.py
index 3776ed9e..c1c4dd9d 100644
--- a/cellpy/utils/plotutils.py
+++ b/cellpy/utils/plotutils.py
@@ -19,7 +19,6 @@
import pandas as pd
import numpy as np
-from cellpycore.config import CurveCols
from cellpycore.legacy import mapping
from cellpy._deprecation import warn_once
@@ -49,10 +48,6 @@
from cellpy.plotting import registry as plot_registry
from cellpy.plotting.theme import make_plotly_template as _make_plotly_template
-# get_cap curve frames use native CurveCols names (#540): potential/cycle_num
-# replace the legacy voltage/cycle (used by cycles_plot below).
-_CCOLS = CurveCols()
-
plotly_available = importlib.util.find_spec("plotly") is not None
seaborn_available = importlib.util.find_spec("seaborn") is not None
@@ -988,7 +983,7 @@ def _create_col_info(self, c: Any) -> tuple[tuple, dict, dict, dict]:
)
y_cols: dict[str, list[str]] = {}
y_transformations: dict[str, dict] = {}
- for family in plot_registry.iter_families():
+ for family in plot_registry.iter_families(entry_point="summary_plot"):
y_cols[family.name] = family.columns(hdr)
transforms = family.transforms(hdr, self.normalize_col)
if transforms:
@@ -2061,57 +2056,6 @@ def _cycle_info_plot_matplotlib(
return ax1, ax2, ax2, ax4
-@dataclasses.dataclass
-class CyclesPlotterConfig:
- """Configuration dataclass for cycles_plot parameters.
-
- Encapsulates all parameters for cycles_plot to improve maintainability
- and enable easier refactoring. Note that 'c' (cellpy object) and 'df'
- (dataframe) are passed separately as they are data objects, not configuration.
- """
-
- # Data objects (computed during function execution)
- form_cycles: Optional[pd.DataFrame] = None
- rest_cycles: Optional[pd.DataFrame] = None
-
- # Plot metadata
- fig_title: Optional[str] = None
- capacity_unit: Optional[str] = None
-
- # Plotly-specific
- plotly_template: Optional[str] = None
- force_colorbar: bool = False
- force_nonbar: bool = False
-
- # Matplotlib-specific
- figsize: tuple = (6, 4)
- cbar_aspect: int = 30
-
- # Common styling
- colormap: str = "Blues_r"
- formation_colormap: str = "autumn"
- cut_colorbar: bool = True
- width: int = 600
- height: int = 400
- marker_size: int = 5
- formation_line_color: str = "rgba(152, 0, 0, .8)"
- xlim: Optional[list[float]] = None
- ylim: Optional[list[float]] = None
-
- # Cycle information
- n_rest_cycles: Optional[int] = None
- n_form_cycles: Optional[int] = None
- show_formation: bool = True
-
- # Seaborn-specific (for matplotlib backend)
- seaborn_style_dict: Optional[dict] = None
- seaborn_context: str = "notebook"
- seaborn_facecolor: str = "#EAEAF2"
- seaborn_edgecolor: str = "black"
- seaborn_style: str = "dark"
- seaborn_palette: str = "deep"
-
-
@notebook_docstring_printer
def cycles_plot(
c,
@@ -2133,7 +2077,8 @@ def cycles_plot(
y_range=None,
xlim=None,
ylim=None,
- interactive=True,
+ backend: Optional[str] = None,
+ interactive: Optional[bool] = None,
return_figure=None,
width=800,
height=600,
@@ -2154,6 +2099,8 @@ def cycles_plot(
cycles are plotted with different colors, and the formation cycles are highlighted with a different colormap.
It is not intended to provide you with high quality plots, but rather to give you a quick overview of the data.
+ Draws through prepare → spec → render (#646).
+
Args:
c: cellpy object containing the data to plot.
cycles (list, optional): List of cycle numbers to plot. If None, all cycles are plotted.
@@ -2170,12 +2117,17 @@ def cycles_plot(
cut_colorbar (bool, optional): Whether to cut the colorbar. Default is True.
title (str, optional): Title of the plot. If None, the cell name is used.
figsize (tuple, optional): Size of the figure for matplotlib. Default is (6, 4).
- xlim (list, optional): Limits for the x-axis.
- ylim (list, optional): Limits for the y-axis.
- interactive (bool, optional): Whether to use interactive plotting (Plotly). Default is True.
- return_figure (bool, optional): Whether to return the figure object. Default is opposite of interactive.
- width (int, optional): Width of the figure for Plotly. Default is 600.
- height (int, optional): Height of the figure for Plotly. Default is 400.
+ x_range (list, optional): Limits for the x-axis.
+ y_range (list, optional): Limits for the y-axis.
+ xlim (list, optional): Deprecated alias for ``x_range`` (removal 2.1).
+ ylim (list, optional): Deprecated alias for ``y_range`` (removal 2.1).
+ backend (str, optional): ``"plotly"`` (default) or ``"matplotlib"``.
+ interactive (bool, optional): Deprecated alias for backend selection
+ (``True``→plotly, ``False``→matplotlib; removal 2.1).
+ return_figure (bool, optional): Whether to return the figure object.
+ Default is ``True`` for matplotlib and ``False`` for plotly (``fig.show()``).
+ width (int, optional): Width of the figure for Plotly. Default is 800.
+ height (int, optional): Height of the figure for Plotly. Default is 600.
marker_size (int, optional): Size of the markers for Plotly. Default is 5.
formation_line_color (str, optional): Color for the formation cycle lines in Plotly. Default is 'rgba(152, 0, 0, .8)'.
force_colorbar (bool, optional): Whether to force the colorbar to be shown. Default is False.
@@ -2201,353 +2153,102 @@ def cycles_plot(
Else:
None: The plot is shown in the default browser.
"""
+ # Resolve backend= vs deprecated interactive= (#646 / same as #639).
+ resolved_backend = backend
+ if interactive is not None:
+ warn_once(
+ "cycles_plot(interactive=...)",
+ 'backend="plotly"|"matplotlib"',
+ removal="2.1",
+ )
+ if resolved_backend is None:
+ resolved_backend = "plotly" if interactive else "matplotlib"
+ if resolved_backend is None:
+ resolved_backend = "plotly"
- if interactive and not plotly_available:
+ if resolved_backend == "plotly" and not plotly_available:
warnings.warn("Can not perform interactive plotting. Plotly is not available.")
- interactive = False
+ resolved_backend = "matplotlib"
if return_figure is None:
- return_figure = not interactive
-
- if cycles is None:
- cycles = c.get_cycle_numbers()
-
- if title is None:
- _bold = "" if interactive else "'"
- _end_bold = "" if interactive else "'"
- _newline = "
" if interactive else "\n"
- _small = '' if interactive else ""
- _end_small = "" if interactive else ""
- top_title_line = f"Capacity plots for {_bold}{c.cell_name}{_end_bold}"
- second_title_line = f"{_small} - {mode} mode"
- if interpolated:
- second_title_line = f"{second_title_line}, interpolated ({number_of_points} points){_end_small}"
- else:
- second_title_line = f"{second_title_line}{_end_small}"
-
- title = _newline.join([top_title_line, second_title_line])
-
- kw_arguments = dict(
- method=method,
- interpolated=interpolated,
- label_cycle_number=True,
- categorical_column=True,
- number_of_points=number_of_points,
- insert_nan=True,
- mode=mode,
- cycle_mode=cycle_mode,
- inter_cycle_shift=inter_cycle_shift,
- )
- df = c.get_cap(cycles=cycles, **kw_arguments)
- # Temporary fix to ensure that the cycles are plotted in the correct order:
- df = df.sort_values(by=[_CCOLS.cycle_num, "direction"])
-
- selector = df[_CCOLS.cycle_num] <= formation_cycles
- form_cycles = df.loc[selector, :]
- rest_cycles = df.loc[~selector, :]
-
- n_form_cycles = len(form_cycles[_CCOLS.cycle_num].unique())
- n_rest_cycles = len(rest_cycles[_CCOLS.cycle_num].unique())
+ return_figure = resolved_backend != "plotly"
- capacity_unit = _get_capacity_unit(c, mode=mode)
-
- # Preparing for more homogeneous parameters:
- if x_range is not None:
- xlim = x_range
- if y_range is not None:
- ylim = y_range
+ # Canonical range spelling: x_range/y_range; xlim/ylim are warn_once aliases.
+ if xlim is not None:
+ warn_once(
+ "cycles_plot(xlim=...)",
+ "cycles_plot(x_range=...)",
+ removal="2.1",
+ )
+ if x_range is None:
+ x_range = xlim
+ if ylim is not None:
+ warn_once(
+ "cycles_plot(ylim=...)",
+ "cycles_plot(y_range=...)",
+ removal="2.1",
+ )
+ if y_range is None:
+ y_range = ylim
- # Extracting seaborn-specific parameters from kwargs (for matplotlib backend):
seaborn_context = kwargs.pop("seaborn_context", "notebook")
seaborn_facecolor = kwargs.pop("seaborn_facecolor", "#EAEAF2")
seaborn_edgecolor = kwargs.pop("seaborn_edgecolor", "black")
seaborn_style_dict = kwargs.pop("seaborn_style_dict", None)
- config = CyclesPlotterConfig(
- form_cycles=form_cycles,
- rest_cycles=rest_cycles,
- fig_title=title,
- capacity_unit=capacity_unit,
- plotly_template=plotly_template,
+ from cellpy.plotting.backends import get_backend
+ from cellpy.plotting.context import from_source
+ from cellpy.plotting.prepare.curves import CyclesPrepareConfig, prepare as prepare_curves
+
+ prep_config = CyclesPrepareConfig(
+ cycles=cycles,
+ inter_cycle_shift=inter_cycle_shift,
+ cycle_mode=cycle_mode,
+ formation_cycles=formation_cycles,
+ show_formation=show_formation,
+ mode=mode,
+ method=method,
+ interpolated=interpolated,
+ number_of_points=number_of_points,
colormap=colormap,
formation_colormap=formation_colormap,
cut_colorbar=cut_colorbar,
- cbar_aspect=30,
+ title=title,
figsize=figsize,
- force_colorbar=force_colorbar,
- force_nonbar=force_nonbar,
- n_rest_cycles=n_rest_cycles,
- n_form_cycles=n_form_cycles,
- show_formation=show_formation,
+ x_range=x_range,
+ y_range=y_range,
width=width,
height=height,
marker_size=marker_size,
formation_line_color=formation_line_color,
- xlim=xlim,
- ylim=ylim,
- seaborn_style=seaborn_style,
+ force_colorbar=force_colorbar,
+ force_nonbar=force_nonbar,
+ plotly_template=plotly_template,
seaborn_palette=seaborn_palette,
+ seaborn_style=seaborn_style,
seaborn_context=seaborn_context,
seaborn_facecolor=seaborn_facecolor,
seaborn_edgecolor=seaborn_edgecolor,
seaborn_style_dict=seaborn_style_dict,
+ backend=resolved_backend,
+ additional_kwargs=dict(kwargs),
)
- if interactive:
- fig = _cycles_plotter_plotly(c, df, config, **kwargs)
- if return_data:
- return fig, df
- elif return_figure:
- return fig
- else:
- fig.show()
-
- else:
- fig = _cycles_plotter_matplotlib(c, df, config, **kwargs)
- if return_figure or return_data:
- plt.close(fig)
- if return_data:
- return fig, df
- elif return_figure:
- return fig
-
-
-def _cycles_plotter_matplotlib(
- c,
- df,
- config: CyclesPlotterConfig,
- **kwargs,
-):
- import numpy as np
- import matplotlib
- from matplotlib.colors import Normalize, ListedColormap
-
- if seaborn_available:
- import seaborn as sns
-
- seaborn_style_dict = config.seaborn_style_dict or {
- "axes.facecolor": config.seaborn_facecolor,
- "axes.edgecolor": config.seaborn_edgecolor,
- }
- sns.set_style(config.seaborn_style, seaborn_style_dict)
- sns.set_palette(config.seaborn_palette)
- sns.set_context(config.seaborn_context)
-
- fig, ax = plt.subplots(1, 1, figsize=config.figsize)
- fig_width, fig_height = config.figsize
-
- if not config.form_cycles.empty and config.show_formation:
- if fig_width < 6:
- logging.critical(
- "Warning: try setting the figsize to (6, 4) or larger for better visualization"
- )
- if fig_width > 8:
- logging.critical(
- "Warning: try setting the figsize to (8, 4) or smaller for better visualization"
- )
- min_cycle, max_cycle = (
- config.form_cycles[_CCOLS.cycle_num].min(),
- config.form_cycles[_CCOLS.cycle_num].max(),
- )
- norm_formation = Normalize(vmin=min_cycle, vmax=max_cycle)
- cycle_sequence = np.arange(min_cycle, max_cycle + 1, 1)
-
- shrink = min(1.0, (1 / 8) * config.n_form_cycles)
-
- c_m_formation = ListedColormap(
- plt.get_cmap(config.formation_colormap, 2 * len(cycle_sequence))(
- cycle_sequence
- )
- )
- s_m_formation = matplotlib.cm.ScalarMappable(
- cmap=c_m_formation, norm=norm_formation
- )
- for name, group in config.form_cycles.groupby(_CCOLS.cycle_num):
- ax.plot(
- group["capacity"],
- group[_CCOLS.potential],
- lw=2, # alpha=0.7,
- color=s_m_formation.to_rgba(name),
- label=f"Cycle {name}",
- )
- cbar_formation = fig.colorbar(
- s_m_formation,
- ax=ax, # label="Formation Cycle",
- ticks=np.arange(
- config.form_cycles[_CCOLS.cycle_num].min(),
- config.form_cycles[_CCOLS.cycle_num].max() + 1,
- 1,
- ),
- shrink=shrink,
- aspect=config.cbar_aspect * shrink,
- location="right",
- anchor=(0.0, 0.0),
- )
- cbar_formation.set_label(
- "Form. Cycle",
- rotation=270,
- labelpad=12,
- )
-
- norm = Normalize(
- vmin=config.rest_cycles[_CCOLS.cycle_num].min(), vmax=config.rest_cycles[_CCOLS.cycle_num].max()
- )
- if config.cut_colorbar:
- cycle_sequence = np.arange(
- config.rest_cycles[_CCOLS.cycle_num].min(), config.rest_cycles[_CCOLS.cycle_num].max() + 1, 1
- )
- n = int(np.round(1.2 * config.rest_cycles[_CCOLS.cycle_num].max()))
- c_m = ListedColormap(plt.get_cmap(config.colormap, n)(cycle_sequence))
- else:
- c_m = plt.get_cmap(config.colormap)
-
- s_m = matplotlib.cm.ScalarMappable(cmap=c_m, norm=norm)
- for name, group in config.rest_cycles.groupby(_CCOLS.cycle_num):
- ax.plot(
- group["capacity"],
- group[_CCOLS.potential],
- lw=1,
- color=s_m.to_rgba(name),
- label=f"Cycle {name}",
- )
- cbar = fig.colorbar(
- s_m,
- ax=ax,
- label="Cycle",
- aspect=config.cbar_aspect,
- location="right",
- )
- cbar.set_label(
- "Cycle",
- rotation=270,
- labelpad=12,
- )
- # cbar.ax.yaxis.set_ticks_position("left")
-
- ax.set_xlabel(f"Capacity ({config.capacity_unit})")
- ax.set_ylabel(with_cellpy_unit("Voltage", "voltage", units=c.cellpy_units))
-
- ax.set_title(config.fig_title, loc="left", wrap=True)
-
- fig.tight_layout()
-
- if config.xlim:
- ax.set_xlim(config.xlim)
- if config.ylim:
- ax.set_ylim(config.ylim)
-
- return fig
-
-
-def _cycles_plotter_plotly(
- c,
- df,
- config: CyclesPlotterConfig,
- **kwargs,
-):
- import plotly.express as px
- import plotly.graph_objects as go
-
- set_plotly_template(config.plotly_template)
-
- color_scales = px.colors.named_colorscales()
- plotly_max_individual_traces_for_lines = kwargs.pop("plotly_max_individual_traces_for_lines", 8)
- if config.colormap not in color_scales:
- colormap = "Blues_r"
- else:
- colormap = config.colormap
-
- if config.cut_colorbar:
- range_color = [df[_CCOLS.cycle_num].min(), 1.2 * df[_CCOLS.cycle_num].max()]
- else:
- range_color = [df[_CCOLS.cycle_num].min(), df[_CCOLS.cycle_num].max()]
- if (
- config.n_rest_cycles is not None
- and config.n_rest_cycles < plotly_max_individual_traces_for_lines
- and not config.force_colorbar
- ) or config.force_nonbar:
- logging.info("using px.line for non-formation cycles")
- show_formation_legend = True
- cmap = px.colors.sample_colorscale(
- colorscale=colormap,
- samplepoints=config.n_rest_cycles,
- low=0.0,
- high=0.8,
- colortype="rgb",
- )
-
- fig = px.line(
- config.rest_cycles,
- x="capacity",
- y=_CCOLS.potential,
- color=_CCOLS.cycle_num,
- title=config.fig_title,
- labels={
- "capacity": f"Capacity ({config.capacity_unit})",
- _CCOLS.potential: with_cellpy_unit("Voltage", "voltage", units=c.cellpy_units),
- },
- color_discrete_sequence=cmap,
- )
-
- else:
- logging.info("using px.scatter for non-formation cycles")
- show_formation_legend = False
- fig = px.scatter(
- config.rest_cycles,
- x="capacity",
- y=_CCOLS.potential,
- title=config.fig_title,
- color=_CCOLS.cycle_num,
- labels={
- "capacity": f"Capacity ({config.capacity_unit})",
- _CCOLS.potential: with_cellpy_unit("Voltage", "voltage", units=c.cellpy_units),
- },
- color_continuous_scale=colormap,
- range_color=range_color,
- )
-
- fig.update_traces(mode="lines+markers", line_color="white", line_width=1)
-
- if not config.form_cycles.empty and config.show_formation:
- for name, group in config.form_cycles.groupby(_CCOLS.cycle_num):
- logging.info(f"using go.Scatter for formation cycle(s) {name}")
- trace = go.Scatter(
- x=group["capacity"],
- y=group[_CCOLS.potential],
- name=f"{name} (f.c.)",
- hovertemplate=f"Formation Cycle {name}
Capacity: %{{x}}
Voltage: %{{y}}",
- mode="lines",
- marker=dict(color=config.formation_line_color),
- showlegend=show_formation_legend,
- legendrank=1,
- legendgroup="formation",
- )
-
- fig.add_trace(trace)
-
- fig.update_traces(marker=dict(size=config.marker_size))
-
- if config.xlim:
- fig.update_xaxes(range=config.xlim)
- if config.ylim:
- fig.update_yaxes(range=config.ylim)
-
- plotly_xaxes_kwargs = kwargs.pop("plotly_xaxes_kwargs", {})
- plotly_yaxes_kwargs = kwargs.pop("plotly_yaxes_kwargs", {})
- if plotly_xaxes_kwargs:
- fig.update_xaxes(**plotly_xaxes_kwargs)
- if plotly_yaxes_kwargs:
- fig.update_yaxes(**plotly_yaxes_kwargs)
-
- plotly_layout_kwargs = kwargs.pop("plotly_layout_kwargs", {})
+ ctx = from_source(c)
+ family = plot_registry.get("cycles")
+ frame, spec = prepare_curves(ctx, family, prep_config)
+ fig = get_backend(resolved_backend).render(frame, spec)
- fig.update_layout(
- height=config.height,
- width=config.width,
- **plotly_layout_kwargs,
- )
+ if resolved_backend == "matplotlib" and (return_figure or return_data):
+ plt.close(fig)
- return fig
+ if return_data:
+ return fig, frame
+ if return_figure:
+ return fig
+ if resolved_backend == "plotly":
+ fig.show()
+ return None
def _cell_and_output_path():
diff --git a/tests/figure_spec_support.py b/tests/figure_spec_support.py
index b49f4b92..8d99b752 100644
--- a/tests/figure_spec_support.py
+++ b/tests/figure_spec_support.py
@@ -145,7 +145,10 @@ def describe_figure(figure) -> dict[str, Any]:
#: Every predefined y-set `summary_plot` accepts — sourced from the PlotFamily
#: registry (#636) so the oracle menu cannot drift from the runtime menu.
-SUMMARY_FAMILIES = tuple(name for name, _description in _plot_families())
+#: Non-summary families (e.g. ``cycles`` for ``cycles_plot``, #646) are excluded.
+SUMMARY_FAMILIES = tuple(
+ name for name, _description in _plot_families(entry_point="summary_plot")
+)
@dataclass(frozen=True)
@@ -241,13 +244,13 @@ def _other_family_cases() -> list[FigureCase]:
FigureCase(
name="cycles_plot[plotly]",
function="cycles_plot",
- kwargs={"interactive": True},
+ kwargs={"backend": "plotly"},
needs_plotly=True,
),
FigureCase(
name="cycles_plot[matplotlib]",
function="cycles_plot",
- kwargs={"interactive": False},
+ kwargs={"backend": "matplotlib"},
),
]
diff --git a/tests/test_cycles_prepare.py b/tests/test_cycles_prepare.py
new file mode 100644
index 00000000..cef8f29d
--- /dev/null
+++ b/tests/test_cycles_prepare.py
@@ -0,0 +1,89 @@
+"""Tests for cycles_plot prepare → spec → render (#646)."""
+
+from __future__ import annotations
+
+import warnings
+
+import pytest
+
+from cellpy.plotting.context import from_source
+from cellpy.plotting.prepare.curves import CyclesPrepareConfig, prepare
+from cellpy.plotting import registry as plot_registry
+from cellpy.utils import plotutils
+from cellpy.utils.plotutils import cycles_plot
+
+
+@pytest.mark.essential
+def test_private_cycles_plotters_are_gone():
+ assert not hasattr(plotutils, "_cycles_plotter_plotly")
+ assert not hasattr(plotutils, "_cycles_plotter_matplotlib")
+ assert not hasattr(plotutils, "CyclesPlotterConfig")
+
+
+@pytest.mark.essential
+def test_prepare_returns_cycles_spec(cell):
+ family = plot_registry.get("cycles")
+ ctx = from_source(cell)
+ config = CyclesPrepareConfig(backend="matplotlib", show_formation=True)
+ frame, spec = prepare(ctx, family, config)
+ assert not frame.empty
+ assert spec.extras.get("kind") == "cycles"
+ assert "form_cycles" in spec.extras
+ assert "rest_cycles" in spec.extras
+ assert spec.supports_formation is True
+
+
+@pytest.mark.essential
+def test_cycles_plot_backend_matplotlib(cell):
+ fig = cycles_plot(cell, backend="matplotlib", return_figure=True)
+ assert fig is not None
+ assert hasattr(fig, "get_axes")
+
+
+@pytest.mark.essential
+def test_cycles_plot_interactive_alias_warns(cell):
+ from cellpy import _deprecation
+
+ _deprecation._WARNED_SITES.clear()
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always", DeprecationWarning)
+ fig = cycles_plot(cell, interactive=False, return_figure=True)
+ 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
+ assert "backend=" in messages[0] or "matplotlib" in messages[0]
+
+
+@pytest.mark.essential
+def test_cycles_plot_xlim_ylim_alias_warns(cell):
+ from cellpy import _deprecation
+
+ _deprecation._WARNED_SITES.clear()
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always", DeprecationWarning)
+ fig = cycles_plot(
+ cell,
+ backend="matplotlib",
+ xlim=[0, 1],
+ ylim=[0, 2],
+ return_figure=True,
+ )
+ assert fig is not None
+ texts = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)]
+ assert any("xlim" in t for t in texts)
+ assert any("ylim" in t for t in texts)
+
+
+@pytest.mark.essential
+def test_cycles_plot_return_data(cell):
+ fig, frame = cycles_plot(
+ cell, backend="matplotlib", return_figure=True, return_data=True
+ )
+ assert fig is not None
+ assert not frame.empty
+ assert "capacity" in frame.columns
diff --git a/tests/test_plotting_registry.py b/tests/test_plotting_registry.py
index d5693569..503128ee 100644
--- a/tests/test_plotting_registry.py
+++ b/tests/test_plotting_registry.py
@@ -39,9 +39,14 @@
@pytest.mark.essential
def test_builtin_families_match_oracle_menu():
- names = tuple(name for name, _ in families())
+ names = tuple(name for name, _ in families(entry_point="summary_plot"))
assert names == EXPECTED_SUMMARY_FAMILIES
assert SUMMARY_FAMILIES == EXPECTED_SUMMARY_FAMILIES
+ # Stage 2 (#646): cycles is registered but is not a summary_plot(y=) name.
+ assert ("cycles", "Voltage vs capacity by cycle") in families(
+ entry_point="cycles_plot"
+ )
+ assert "cycles" not in SUMMARY_FAMILIES
@pytest.mark.essential