From 88b1b7cee6eeb7c6db7b3b528fe98027d0bf7f21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 07:36:33 +0000 Subject: [PATCH 1/5] chore(issueflows): publish epic #567 Stage 2 and capture #646 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record Published #646–#648 in epic567_plan.md and seed focus tracking for Port cycles_plot to prepare→spec→render. Co-authored-by: Jan Petter Maehlen --- .../01-current-issues/issue646_original.md | 25 +++++++++++++++++++ .issueflows/05-epics/epic567_plan.md | 7 ++++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .issueflows/01-current-issues/issue646_original.md diff --git a/.issueflows/01-current-issues/issue646_original.md b/.issueflows/01-current-issues/issue646_original.md new file mode 100644 index 00000000..73b509d1 --- /dev/null +++ b/.issueflows/01-current-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/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 From bf668065ee0b589ca9bb082ead2e1659bf27bd29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 08:15:21 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(issueflows):=20plan=20#646=20cycles=5F?= =?UTF-8?q?plot=20prepare=E2=86=92spec=E2=86=92render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the confirmed approach for Stage 2: prepare/curves, registry family, backend cycles branch, and range/backend deprecations. Co-authored-by: Jan Petter Maehlen --- .../01-current-issues/issue646_plan.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .issueflows/01-current-issues/issue646_plan.md diff --git a/.issueflows/01-current-issues/issue646_plan.md b/.issueflows/01-current-issues/issue646_plan.md new file mode 100644 index 00000000..8dcfdc6f --- /dev/null +++ b/.issueflows/01-current-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). From 0271e2ccb52c055803c47b3afba56118c1649b93 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 08:35:17 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Port=20cycles=5Fplot=20to=20prepare?= =?UTF-8?q?=E2=86=92spec=E2=86=92render=20(#646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prepare/curves.py and a cycles PlotFamily, route public cycles_plot through get_backend().render with kind=cycles branches in plotly/mpl, and deprecate interactive=/xlim/ylim in favor of backend=/x_range/y_range. Co-authored-by: Jan Petter Maehlen --- .../01-current-issues/issue646_status.md | 18 + .../plotting-backends.md | 13 +- .../04-designs-and-guides/plotting-prepare.md | 16 +- .../plotting-registry.md | 5 +- DEPRECATIONS.md | 3 + cellpy/_deprecation.py | 16 + cellpy/plotting/backends/mpl.py | 143 ++++++ cellpy/plotting/backends/plotly.py | 154 +++++- cellpy/plotting/prepare/__init__.py | 5 +- cellpy/plotting/prepare/curves.py | 213 ++++++++ cellpy/plotting/registry.py | 50 +- cellpy/utils/plotutils.py | 467 ++++-------------- tests/figure_spec_support.py | 9 +- tests/test_cycles_prepare.py | 89 ++++ tests/test_plotting_registry.py | 7 +- 15 files changed, 797 insertions(+), 411 deletions(-) create mode 100644 .issueflows/01-current-issues/issue646_status.md create mode 100644 cellpy/plotting/prepare/curves.py create mode 100644 tests/test_cycles_prepare.py diff --git a/.issueflows/01-current-issues/issue646_status.md b/.issueflows/01-current-issues/issue646_status.md new file mode 100644 index 00000000..00177579 --- /dev/null +++ b/.issueflows/01-current-issues/issue646_status.md @@ -0,0 +1,18 @@ +# Issue #646 — Status + +- [ ] 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. + +## Remaining work + +- Essential suite confirmation. +- `/iflow-close` (HISTORY, archive, PR finalize). 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/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/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 From 3e2aba8cf2174e62f11dbbc0913fd42d4cd9dcb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 08:35:25 +0000 Subject: [PATCH 4/5] docs(issueflows): note essential suite green for #646 Co-authored-by: Jan Petter Maehlen --- .issueflows/01-current-issues/issue646_status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.issueflows/01-current-issues/issue646_status.md b/.issueflows/01-current-issues/issue646_status.md index 00177579..3e541505 100644 --- a/.issueflows/01-current-issues/issue646_status.md +++ b/.issueflows/01-current-issues/issue646_status.md @@ -11,8 +11,8 @@ - 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. ## Remaining work -- Essential suite confirmation. - `/iflow-close` (HISTORY, archive, PR finalize). From 650cdc9548136713d8898fd4eb6b56053b4a0e5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 08:51:14 +0000 Subject: [PATCH 5/5] Close #646: HISTORY bullet and archive issue-flow docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the cycles_plot prepare→spec→render port under Unreleased and move the issue group to 03-solved-issues with Done checked. Co-authored-by: Jan Petter Maehlen --- .../issue646_original.md | 0 .../{01-current-issues => 03-solved-issues}/issue646_plan.md | 0 .../issue646_status.md | 5 +++-- HISTORY.md | 2 ++ 4 files changed, 5 insertions(+), 2 deletions(-) rename .issueflows/{01-current-issues => 03-solved-issues}/issue646_original.md (100%) rename .issueflows/{01-current-issues => 03-solved-issues}/issue646_plan.md (100%) rename .issueflows/{01-current-issues => 03-solved-issues}/issue646_status.md (87%) diff --git a/.issueflows/01-current-issues/issue646_original.md b/.issueflows/03-solved-issues/issue646_original.md similarity index 100% rename from .issueflows/01-current-issues/issue646_original.md rename to .issueflows/03-solved-issues/issue646_original.md diff --git a/.issueflows/01-current-issues/issue646_plan.md b/.issueflows/03-solved-issues/issue646_plan.md similarity index 100% rename from .issueflows/01-current-issues/issue646_plan.md rename to .issueflows/03-solved-issues/issue646_plan.md diff --git a/.issueflows/01-current-issues/issue646_status.md b/.issueflows/03-solved-issues/issue646_status.md similarity index 87% rename from .issueflows/01-current-issues/issue646_status.md rename to .issueflows/03-solved-issues/issue646_status.md index 3e541505..990fa03a 100644 --- a/.issueflows/01-current-issues/issue646_status.md +++ b/.issueflows/03-solved-issues/issue646_status.md @@ -1,6 +1,6 @@ # Issue #646 — Status -- [ ] Done +- [x] Done ## What's done @@ -12,7 +12,8 @@ - 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 -- `/iflow-close` (HISTORY, archive, PR finalize). +- None (merge + `/iflow-cleanup` after PR lands). 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).