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