From 61970f065a5de965a182c27dadb920f226bde6e0 Mon Sep 17 00:00:00 2001 From: jepegit Date: Thu, 23 Jul 2026 18:41:48 +0200 Subject: [PATCH 1/2] Record published Stage 3 issues for epic #567. --- .issueflows/05-epics/epic567_plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.issueflows/05-epics/epic567_plan.md b/.issueflows/05-epics/epic567_plan.md index 6d3ef7e3..d9ff22dd 100644 --- a/.issueflows/05-epics/epic567_plan.md +++ b/.issueflows/05-epics/epic567_plan.md @@ -198,6 +198,7 @@ Closes the epic acceptance criteria: one drawing home, batch facade unchanged, stays safe without the `batch` extra for non-plot imports. - Depends on: stage 2 issue 2 - yolo: no — large behavioural surface; collection API must not break. +- Published: #657 ### Issue: Wire `Batch.plot()` to plotting and delete `batch_plotters.py` @@ -214,6 +215,7 @@ Closes the epic acceptance criteria: one drawing home, batch facade unchanged, docs mention the backend triage. - Depends on: stage 3 issue 1 - yolo: no — deletes a 1.5k-line module; user-facing batch entry. +- Published: #658 ## Later (unstaged) From c24854d39661c50808e9064e6b5499dffc8f7e47 Mon Sep 17 00:00:00 2001 From: jepegit Date: Thu, 23 Jul 2026 20:00:00 +0200 Subject: [PATCH 2/2] Re-base collectors' drawing half onto cellpy.plotting. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move multi-cell plotters into collected_plot with layout/kind options so BatchCollector.render delegates into the shared prepare→spec→render home and collectors no longer own figure drawing. Co-authored-by: Cursor --- .../03-solved-issues/issue657_original.md | 26 + .issueflows/03-solved-issues/issue657_plan.md | 192 +++ .../03-solved-issues/issue657_status.md | 15 + .../plotting-collected.md | 33 + .../04-designs-and-guides/plotting-prepare.md | 5 +- HISTORY.md | 2 + cellpy/plotting/__init__.py | 9 +- cellpy/plotting/backends/mpl.py | 5 + cellpy/plotting/backends/plotly.py | 4 + cellpy/plotting/collected.py | 1476 +++++++++++++++++ cellpy/plotting/context.py | 44 +- cellpy/utils/collectors.py | 1303 +-------------- tests/data/collector_figure_specs.json | 766 +++++++++ tests/test_collectors.py | 110 ++ 14 files changed, 2727 insertions(+), 1263 deletions(-) create mode 100644 .issueflows/03-solved-issues/issue657_original.md create mode 100644 .issueflows/03-solved-issues/issue657_plan.md create mode 100644 .issueflows/03-solved-issues/issue657_status.md create mode 100644 .issueflows/04-designs-and-guides/plotting-collected.md create mode 100644 cellpy/plotting/collected.py create mode 100644 tests/data/collector_figure_specs.json diff --git a/.issueflows/03-solved-issues/issue657_original.md b/.issueflows/03-solved-issues/issue657_original.md new file mode 100644 index 00000000..7ebcf617 --- /dev/null +++ b/.issueflows/03-solved-issues/issue657_original.md @@ -0,0 +1,26 @@ +# Issue #657: Re-base collectors' drawing half onto `cellpy.plotting` + +Source: https://github.com/jepegit/cellpy/issues/657 + +## Original issue text + +## Context + +Part of epic #567 (Stage 3 — Collectors drawing half, Batch.plot, retire batch_plotters). Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md` (plus batch redesign §4.7 and collectors redesign §3.3 for the hand-offs). Collection, caching, autonaming, and frame persistence stay in collectors (out of scope to redesign). + +## Scope + +Delete collectors' plotter implementations (`sequence_plotter`, `summary_plotter`, `cycles_plotter`, `spread_plot` drawing bodies) and local templates; `BatchCollector.plot` / render path calls `cellpy.plotting.*` with the already-collected tidy frame (`cell`/`group`/`sub_group` columns). Fold fig-per-cell / fig-per-cycle / film / spread capabilities into plotting options (`layout=`, `kind=`) as needed for parity. + +## Acceptance + +- Collector-driven figures that the maintainers' docs/tutorials exercise match the oracle (add collector/batch-input cases to `figure_specs.json` as needed). +- Collectors no longer define figure IO, legend helpers, or plotly templates locally. +- `import cellpy.utils.collectors` stays safe without the `batch` extra for non-plot imports. + +## Depends on + +#647 + +Part of epic #567. + diff --git a/.issueflows/03-solved-issues/issue657_plan.md b/.issueflows/03-solved-issues/issue657_plan.md new file mode 100644 index 00000000..b0673c65 --- /dev/null +++ b/.issueflows/03-solved-issues/issue657_plan.md @@ -0,0 +1,192 @@ +# Issue #657 — Plan + +## Goal + +Re-base collectors' drawing half onto `cellpy.plotting`: already-collected tidy +frames (`cell` / `group` / `sub_group`) render through prepare→spec→render with +`layout=` / `kind=` options; delete local plotter bodies; keep collection, +caching, autonaming, and frame persistence in collectors. + +## Constraints + +- Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md` §3.3 / + Phase 4; collectors hand-off in + `architecture-plan/cellpy2-collectors-redesign-plan.md` §3.3. +- Collection redesign (options dataclasses, elevated-args collapse, recipes) is + **out of scope** — leave `data_collector` / `plotter_arguments` wiring; only + swap the draw callee. +- `#658` (`Batch.plot` + delete `batch_plotters`) depends on this landing; do + not touch `batch_plotters.py` here. +- Filters stay in `cellpy/filters/` + (`.issueflows/04-designs-and-guides/filters-and-plot-filtering.md`). +- Parity gate: keep `tests/test_figure_specs.py` green; extend + `figure_specs.json` with collector/batch-input cases in the same commit as + intentional visual changes. Plotting tests under `MPLBACKEND=Agg`. +- `import cellpy.utils.collectors` must stay safe without the `batch` extra for + **non-plot** imports (no import-time plotly attribute use; defer heavy plot + deps). +- Public collector UX (`Batch*Collector`, `render` / `show` / `update`, + `plot_type` → `method`) stays working; notebooks must not break. + +### Prior art + +- `BatchCollector.render` → `self.plotter(self.data, …)` — + [`cellpy/utils/collectors.py`](cellpy/utils/collectors.py) (~L421); plotters + `sequence_plotter` / `summary_plotter` / `cycles_plotter` / `ica_plotter` / + `spread_plot` (~L1765–2912). **Migrate** draw bodies out; keep collect path. +- `CellContext` / `from_source` — + [`cellpy/plotting/context.py`](cellpy/plotting/context.py); docstring already + reserves Batch/frame adapters for this issue. **Extend**. +- Prepare → `FigureSpec` → `get_backend().render` — + [`cellpy/plotting/prepare/`](cellpy/plotting/prepare/), + [`backends/`](cellpy/plotting/backends/); design notes in + `.issueflows/04-designs-and-guides/plotting-prepare.md` + + `plotting-backends.md`. **Mirror** for collected frames. +- Collector plotly templates already in + [`cellpy/plotting/theme.py`](cellpy/plotting/theme.py) + (`make_collector_templates`, names `fig_pr_cell` / `fig_pr_cycle` / `film` / + `summary`). **Reuse**; stop treating collectors as template owner. +- Figure IO / legend helpers already in `plotting.figures` / `plotting.labels` + (Phase 1 / #595); collectors re-export. **Keep** re-exports or thin imports; + delete any remaining local exporter duplication that is purely drawing. +- Oracle: + [`tests/test_figure_specs.py`](tests/test_figure_specs.py) + + [`tests/figure_spec_support.py`](tests/figure_spec_support.py) — single-cell + only today. **Extend** with collector/frame cases. +- Smoke: + [`tests/test_collectors.py`](tests/test_collectors.py) — E2E autorun for + summary / cycles / ICA (+ film). **Keep green**. +- Toolbox (`00-tools/`): nothing that builds collector figures. Graph communities + 35/58/163 (collectors) and plotting prepare/backends hubs. + +## Approach + +**Recommended: frame-in adapter + multi-cell render in `cellpy.plotting`, then +delete collectors plotters.** Do not leave `sequence_plotter` as a permanent +public API under a new path; fold behaviour into backends/`layout`/`kind`. + +### 1. Frame context + +- Add `FrameContext` (name flexible) in `context.py`: holds the tidy + `DataFrame` plus optional `units` / `journal` / collector `kind` + (`summary` | `cycles` | `ica`). +- Extend `from_source` (or add `from_frame`) so a collected frame is a first-class + plotting input without requiring a live `CellpyCell` per subplot. +- Do **not** implement full `Batch` → collect inside plotting; collectors keep + owning collection. + +### 2. Collected-frame plot API in plotting + +- Add one orchestrator used by collectors, e.g. + `cellpy.plotting.collected_plot(frame, *, family_kind, layout=…, kind=…, + backend=…, **opts)` (exact name bikeshed OK; keep it package-private or + public-but-narrow). +- Map today's collector knobs: + - `method` / `plot_type`: `fig_pr_cell` → `layout="per_cell"`; + `fig_pr_cycle` → `layout="per_cycle"`; `film` → `kind="film"` (+ layout); + `summary` → summary collected path. + - `spread=True` → `kind="spread"`. + - Default line plots → `kind="line"`. +- Build a `FigureSpec` (panels / extras carrying layout+kind+labels) from the + already-tidy frame; skip single-cell prepare that expects `CellContext`. +- Render via existing `get_backend(backend)` with new multi-cell branches in + plotly (primary). Port the behavioural core of `sequence_plotter` / + `spread_plot` / summary melt path into those branches (mechanical move + + rename to `layout`/`kind`, not a redesign of collection). + +### 3. Wire collectors + +- `BatchCollector.render` (and optionally `plot` as a thin alias for redesign + §3.3 naming) calls the plotting orchestrator with `self.data` + mapped + options; drop `self.plotter` callable requirement **or** replace subclass + plotter defaults with a sentinel that means “use plotting”. +- Subclasses keep elevated `plot_type` / collector args; only the draw target + changes. +- Figure save / PNG / SVG paths use `plotting.figures` (delete duplicate local + plotly exporters if still present as bodies). +- Templates: call `theme.make_collector_templates()` from plotting/orchestrator + (or once on first collected render); collectors stop owning template setup + beyond a no-op or re-export. + +### 4. Delete drawing bodies + +Remove from `collectors.py` (or reduce to deprecated shims that forward one +release if something external imported them — prefer delete if only used +internally): + +- `sequence_plotter`, `_cycles_plotter`, `summary_plotter`, `cycles_plotter`, + `ica_plotter`, `spread_plot`, film helpers that exist only for drawing + (`histogram_equalization` / `_hist_eq` as needed). +- Keep: collectors, `pick_named_cell`, `*_collector` frame builders, + persistence, autoname, units parsing. + +### 5. Import hygiene + +- Keep plotly import guarded; ensure no import-time `go`/`px`/`pio` use outside + functions (existing `test_plotting_package` AST guard). +- Soften hard `matplotlib` import at collectors module top if it blocks + non-plot import paths; match the plotly pattern where practical. + +### 6. Docs / deprecations + +- If any public plotter name was importable from `cellpy.utils.collectors`, + register a `warn_once` shim in `DEPRECATIONS.md` pointing at the plotting + API — only if grep shows external/doc usage; otherwise delete cleanly. +- Short note in + `.issueflows/04-designs-and-guides/plotting-prepare.md` (or new + `plotting-collected.md`): FrameContext + `layout`/`kind` contract. + +### Ordering inside the PR + +1. FrameContext + collected orchestrator + plotly multi-cell render (dual-path + behind collectors still calling old plotters until green). +2. Flip `BatchCollector.render` to the new path; run collector + oracle tests. +3. Delete old plotter bodies; regenerate `figure_specs.json` for new cases. +4. Import hygiene + design-doc touch-up. + +## Files to touch + +| Path | Change | +|------|--------| +| `cellpy/plotting/context.py` | `FrameContext` (+ `from_frame` / extend `from_source`) | +| `cellpy/plotting/` (new module, e.g. `collected.py` or `prepare/collected.py`) | Orchestrator: frame → FigureSpec → backend | +| `cellpy/plotting/backends/plotly.py` | Multi-cell `layout` / `kind` render branches | +| `cellpy/plotting/backends/mpl.py` | Parity only where collectors already support seaborn/mpl today; else document plotly-primary for collected layouts | +| `cellpy/plotting/theme.py` | Ensure collector templates registered from plotting side | +| `cellpy/plotting/__init__.py` | Export new public/narrow API as needed | +| `cellpy/utils/collectors.py` | `render`/`plot` → plotting; delete plotter bodies; import hygiene | +| `tests/figure_spec_support.py` + `tests/data/figure_specs.json` | Collector/batch-input cases + snapshot | +| `tests/test_figure_specs.py` / `dev/snapshot_figure_specs.py` | Wire new cases if needed | +| `tests/test_collectors.py` | Stay green; adjust imports if plotters vanish | +| `tests/test_plotting_package.py` | Re-exports / no import-time plotly | +| `DEPRECATIONS.md` | Only if public shims needed | +| `.issueflows/04-designs-and-guides/plotting-*.md` | Record FrameContext / layout/kind decision | + +## Test strategy + +```bash +MPLBACKEND=Agg uv run pytest tests/test_collectors.py tests/test_plotting_package.py tests/test_figure_specs.py -q +MPLBACKEND=Agg uv run pytest -m essential -q +``` + +- Extend oracle with a **minimum** collector column (recommend): summary default + (plotly), cycles `per_cell` (plotly), ICA `film` (plotly), and one `spread` + summary if `standard_gravimetric_collector` / spread path is still public. +- Keep existing `test_collectors.py` E2E autorun assertions. +- Regenerate snapshot via `uv run python dev/snapshot_figure_specs.py` when + structure intentionally changes. + +## Decisions (confirmed) + +1. **Backend parity:** plotly-complete for all `layout`/`kind` modes; + `backend="seaborn"` → matplotlib backend with best-effort parity (or + `warn_once` + plotly fallback if parity is incomplete). +2. **Frame-in API:** narrow public `cellpy.plotting.collected_plot` (name OK to + adjust slightly) so #658 can call it without `BatchCollector`. +3. **`plot` alias:** add `BatchCollector.plot` as a thin alias of `render`. +4. **Scope:** one PR, full delete of collectors plotter bodies (phased commits + inside the PR). + +## Status + +- [x] Plan accepted — ready for `/iflow-build` diff --git a/.issueflows/03-solved-issues/issue657_status.md b/.issueflows/03-solved-issues/issue657_status.md new file mode 100644 index 00000000..6ef5b6e8 --- /dev/null +++ b/.issueflows/03-solved-issues/issue657_status.md @@ -0,0 +1,15 @@ +# Issue #657 — Status + +- [x] Done + +## What's done + +- Re-based collectors drawing onto `cellpy.plotting.collected_plot` (`layout=` / `kind=`). +- Moved plotter bodies out of `collectors.py`; `BatchCollector.render` / `plot` delegate. +- `FrameContext` + design note `plotting-collected.md`. +- Collector figure oracle (`tests/data/collector_figure_specs.json`) + tests. +- Focused suite green (collectors / plotting_package / figure_specs). + +## Remaining work + +- None. diff --git a/.issueflows/04-designs-and-guides/plotting-collected.md b/.issueflows/04-designs-and-guides/plotting-collected.md new file mode 100644 index 00000000..2dd6c541 --- /dev/null +++ b/.issueflows/04-designs-and-guides/plotting-collected.md @@ -0,0 +1,33 @@ +# Collected-frame plotting (`layout=` / `kind=`) + +## Context + +Epic #567 Stage 3 / issue #657 re-bases collectors' drawing half onto +`cellpy.plotting`. Collection, caching, and autonaming stay in +`cellpy.utils.collectors`; drawing uses an already-tidy multi-cell frame. + +## Decision + +- **`FrameContext`** / **`from_frame`** in `cellpy.plotting.context` wrap a + collected frame plus light metadata (`family_kind`, `units`, `journal`). +- **Public orchestrator:** `cellpy.plotting.collected_plot(frame, *, + family_kind, layout=…, kind=…, backend=…)`. +- Legacy collector knobs map as: + - `method`/`plot_type` `fig_pr_cell` → `layout="per_cell"` + - `fig_pr_cycle` → `layout="per_cycle"` + - `film` → `kind="film"` + - `spread=True` → `kind="spread"` + - default → `kind="line"` +- Flow: resolve layout/kind → `FigureSpec(extras["kind"]="collected", …)` → + `get_backend(...).render` → collected layout engines (plotly primary; + `seaborn` / `matplotlib` → historical seaborn collector path, best-effort). +- **`BatchCollector.render`** (and thin **`plot`** alias) call + `collected_plot`; custom `plotter=` callables still work if provided. +- Collector plotly templates remain in `cellpy.plotting.theme` + (`make_collector_templates`). + +## Links + +- Issue #657; epic #567; plan + `architecture-plan/cellpy2-plotting-redesign-plan.md` §3.3 / Phase 4 +- Related: `plotting-prepare.md`, `plotting-backends.md` diff --git a/.issueflows/04-designs-and-guides/plotting-prepare.md b/.issueflows/04-designs-and-guides/plotting-prepare.md index 203fdfb2..a9ef8189 100644 --- a/.issueflows/04-designs-and-guides/plotting-prepare.md +++ b/.issueflows/04-designs-and-guides/plotting-prepare.md @@ -39,8 +39,9 @@ backend.render as the only summary path. Stage 2 extends the same contract to style (plotly hover carries `direction`). - **`LiveHeaders`** lives in `cellpy/plotting/headers.py` (re-exported as `plotutils._LiveHeaders`). -- **`CellContext`** in `cellpy/plotting/context.py` is the thin cell adapter; - BatchContext waits for collectors rebase. +- **`CellContext`** in `cellpy/plotting/context.py` is the thin cell adapter. + Collected multi-cell frames use **`FrameContext`** / **`collected_plot`** + (`layout=` / `kind=`) — see `plotting-collected.md` (#657). - Public `summary_plot` / `cycles_plot` / `raw_plot` / `cycle_info_plot` / `ica_plot` / `dva_plot` stay in `plotutils` and orchestrate context → registry → prepare → `get_backend(backend).render`. diff --git a/HISTORY.md b/HISTORY.md index f96437eb..88c5c3e2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -60,6 +60,8 @@ (#599). `harmonize()` raises if a schema cast empties a column; new `LoaderDeclarations.duration_columns` for string durations (loader authors). +* Re-base collectors' drawing half onto `cellpy.plotting` (#657). + ## [2.0.0a6] - 2026-07-22 * **Breaking:** `CellpyCell.get_cap` now returns native `cellpycore` curve diff --git a/cellpy/plotting/__init__.py b/cellpy/plotting/__init__.py index dea639e9..8ebf5dfc 100644 --- a/cellpy/plotting/__init__.py +++ b/cellpy/plotting/__init__.py @@ -16,7 +16,8 @@ | [`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) | +| [`context`][cellpy.plotting.context] | ``CellContext`` / ``FrameContext`` (#638 / #657) | +| [`collected`][cellpy.plotting.collected] | multi-cell ``collected_plot`` (``layout=`` / ``kind=``) (#657) | The old locations re-export from here, so nothing that imported them breaks. Public ``summary_plot`` still lives in ``plotutils`` but runs @@ -31,7 +32,8 @@ PlotlyBackend, get_backend, ) -from cellpy.plotting.context import CellContext, from_source +from cellpy.plotting.collected import collected_plot +from cellpy.plotting.context import CellContext, FrameContext, from_frame, from_source from cellpy.plotting.cycle_legend import ( DEFAULT_LEGEND_CYCLE_LIMIT, resolve_cycle_legend_mode, @@ -65,12 +67,15 @@ "CellContext", "DEFAULT_LEGEND_CYCLE_LIMIT", "FigureSpec", + "FrameContext", "MatplotlibBackend", "PanelSpec", "PlotFamily", "PlotlyBackend", "_register_family", + "collected_plot", "families", + "from_frame", "from_source", "get_backend", "get_family", diff --git a/cellpy/plotting/backends/mpl.py b/cellpy/plotting/backends/mpl.py index b1571153..df92b3d5 100644 --- a/cellpy/plotting/backends/mpl.py +++ b/cellpy/plotting/backends/mpl.py @@ -64,6 +64,11 @@ def render(self, frame: Any, spec: FigureSpec) -> Any: """Render a tidy frame according to *spec* (matplotlib Figure).""" extras = dict(spec.extras or {}) kind = extras.get("kind") + if kind == "collected": + # Collectors historically used seaborn for non-plotly layouts (#657). + from cellpy.plotting.collected import render_collected + + return render_collected(frame, spec, backend_override="seaborn") if kind == "cycles": return self._render_cycles(frame, spec) if kind == "raw": diff --git a/cellpy/plotting/backends/plotly.py b/cellpy/plotting/backends/plotly.py index 799b6782..0a93ba4d 100644 --- a/cellpy/plotting/backends/plotly.py +++ b/cellpy/plotting/backends/plotly.py @@ -297,6 +297,10 @@ def __init__(self) -> None: def render(self, frame: Any, spec: FigureSpec) -> Any: extras = dict(spec.extras or {}) kind = extras.get("kind") + if kind == "collected": + from cellpy.plotting.collected import render_collected + + return render_collected(frame, spec, backend_override="plotly") if kind == "cycles": return self._render_cycles(frame, spec) if kind == "raw": diff --git a/cellpy/plotting/collected.py b/cellpy/plotting/collected.py new file mode 100644 index 00000000..b09f1336 --- /dev/null +++ b/cellpy/plotting/collected.py @@ -0,0 +1,1476 @@ +"""Collected-frame plotting: multi-cell layout/kind render (#657). + +Collectors own collection; this module owns drawing for already-tidy frames +with ``cell`` / ``group`` / ``sub_group`` columns. Public entry: +:func:`collected_plot` → ``FigureSpec`` → backend.render. +""" + +from __future__ import annotations + +import functools +import logging +import math +import warnings +from typing import Any, Optional + +import numpy as np +import pandas as pd + +from cellpycore.config import CurveCols +from cellpy.parameters.internal_settings import get_headers_journal +from cellpy.plotting.labels import legend_replacer, remove_markers +from cellpy.plotting import theme +from cellpy.plotting.spec import FigureSpec + +logger = logging.getLogger(__name__) + +_CCOLS = CurveCols() +hdr_journal = get_headers_journal() + +DEFAULT_CYCLES = [1, 10, 20] +PLOTLY_BASE_TEMPLATE = "plotly" +MAX_POINTS_SEABORN_FACET_GRID = 60_000 + +supported_backends: list[str] = [] +px = None +go = None +pio = None +plt = None +sns = None + +try: + import plotly + import plotly.express as px + import plotly.graph_objects as go + import plotly.io as pio + + supported_backends.append("plotly") +except ImportError: + plotly = None # type: ignore[assignment] + +try: + import matplotlib.pyplot as plt + + # matplotlib alone is not a collectors layout backend; seaborn owns that path. +except ImportError: + plt = None + +try: + import seaborn as sns + + supported_backends.append("seaborn") +except ImportError: + sns = None + + +# Internal plotter implementations (moved from collectors.py; #657) + + + + +def _hist_eq(trace): + z = histogram_equalization(trace.z) + trace.update(z=z) + return trace + + +def y_axis_replacer(ax, label): + """Replace y-axis label in matplotlib plots.""" + if isinstance(label, dict): + _label = label.get(ax.title.text, None) + if _label is None: + _label = list(label.values())[0] + ax.update(title_text=_label) + else: + ax.update(title_text=label) + return ax + + + + +def _plotly_y_label_cleaner(y_label_mapper, split_at=20): + """Clean up the y-label mapper for plotly. + + The y-label mapper is a dictionary that maps the variable name to the y-label. The y-labels are + expected to be in the form of "Variable Name (unit)". If the y-label is too long, it is + split into multiple lines. + This is done to avoid the y-labels from being too long and wrapping around. + + Discharge Capacity Retention Gravimetric Norm (%) should become: + Discharge Capacity
Retention Gravimetric Norm
(%) + + Args: + y_label_mapper (dict): the y-label mapper. + + Returns: + dict: the cleaned up y-label mapper. + + """ + + new_y_label_mapper = {} + for k, v in y_label_mapper.items(): + if len(v) > split_at: + # First split on " (" pattern + v = "
(".join(v.split(" (")) + + # Then check if any resulting line is still too long and split on spaces + lines = v.split("
") + final_lines = [] + for line in lines: + if len(line) > split_at and " " in line: + # Split long lines on spaces + words = line.split(" ") + current_line = "" + for word in words: + if len(current_line + " " + word) > split_at and current_line: + final_lines.append(current_line) + current_line = word + else: + if current_line: + current_line += " " + word + else: + current_line = word + if current_line: + final_lines.append(current_line) + else: + final_lines.append(line) + v = "
".join(final_lines) + new_y_label_mapper[k] = v + return new_y_label_mapper + + +def spread_plot(curves, plotly_arguments=None, y_label_mapper=None, **kwargs): + """Create a spread plot (error-bands instead of error-bars). + + This is an experimental feature that is not yet fully tested. It uses make_subplots to create the figure, + and then adds the traces one by one. This methodology will eventually replace the use of plotly.express + for all the summary plots. + + """ + from plotly.subplots import make_subplots + + if y_label_mapper is None: + y_label_mapper = {} + else: + y_label_mapper = _plotly_y_label_cleaner(y_label_mapper) + + selected_variables = curves["variable"].unique() + number_of_rows = len(selected_variables) + # TODO: change this (only temporary fix to allow height fractions to be set by spread_plot) + height_fractions = kwargs.get( + "height_fractions_spread", [1 / number_of_rows] * number_of_rows + ) + + colors = plotly.colors.qualitative.Plotly + opacity = 0.2 + color_list = [] + for color in colors: + color_rgb = plotly.colors.hex_to_rgb(color) + color_rgb_main = f"rgb({color_rgb[0]}, {color_rgb[1]}, {color_rgb[2]})" + color_rgba_spread = ( + f"rgba({color_rgb[0]}, {color_rgb[1]}, {color_rgb[2]}, {opacity})" + ) + color_list.append((color_rgb_main, color_rgba_spread)) + + if plotly_arguments.get("markers"): + mode = "lines+markers" + else: + mode = "lines" + + g = curves.groupby("cell") + fig = make_subplots( + rows=number_of_rows, + cols=1, + start_cell=plotly_arguments.get("plotly_start_cell", "top-left"), + shared_xaxes=plotly_arguments.get("plotly_shared_xaxes", True), + row_heights=height_fractions, + vertical_spacing=plotly_arguments.get("plotly_vertical_spacing", 0.01), + ) + y_labels = {} + for i, (cell, data) in enumerate(g): + color = color_list[i % len(color_list)] + + for row_number, variable in enumerate(selected_variables): + y_label = y_label_mapper.get(variable, variable) + y_labels[row_number] = y_label + if row_number == 0: + show_legend = True + else: + show_legend = False + sub_data = data[data["variable"] == variable] + fig.add_trace( + go.Scatter( + name=cell, + x=sub_data["cycle"], + y=sub_data["mean"], + mode=mode, + line=dict(color=color[0]), + legendgroup=cell, + legendgrouptitle=None, + showlegend=show_legend, + ), + row=row_number + 1, + col=1, + ) + fig.add_trace( + go.Scatter( + name=f"Upper Bound {cell}", + x=sub_data["cycle"], + y=sub_data["mean"] + sub_data["std"], + mode="lines", + marker=dict( + color=color[1], + ), + line=dict(width=0), + showlegend=False, + legendgroup=cell, + ), + row=row_number + 1, + col=1, + ) + fig.add_trace( + go.Scatter( + name=f"Lower Bound {cell}", + x=sub_data["cycle"], + y=sub_data["mean"] - sub_data["std"], + mode="lines", + marker=dict( + color=color[1], + ), + line=dict(width=0), + fillcolor=color[1], + fill="tonexty", + showlegend=False, + legendgroup=cell, + ), + row=row_number + 1, + col=1, + ) + for row_number, y_label in y_labels.items(): + fig.update_yaxes(title_text=y_label, row=row_number + 1, col=1) + + fig.update_layout(legend_tracegroupgap=0) + # fig.update_layout(hovermode="x") + + if labels := plotly_arguments.get("labels"): + fig.update_xaxes(title=labels.get("cycle", None), row=number_of_rows) + + # Hack to remove the x-axis title that appears on the top of the plot: + # if number_of_rows > 1: + # fig.update_layout(xaxis_title=None) + + if hover_mode := kwargs.pop("hovermode", None): + fig.update_layout(hovermode=hover_mode) + + return fig + + +def _select_direction(curves, direction, direction_col="direction"): + """Select one direction from a collected curve frame. + + Handles both direction encodings that reach the plotters: + + - The specced ICA frame (#566) spells direction out ("charge" / + "discharge"), **cell-centric** per decision #591. + - Frames straight from ``get_cap(categorical_column=True)`` still carry + the raw ±1 half-cycle code. For those the historical mapping is kept + (-1 selected as "charge") so non-ICA film plots are unchanged; the code + is positional, and relabelling it needs the cell's cycle_mode, which a + collected frame no longer knows. + """ + if direction_col not in curves.columns: + logging.debug( + "no %r column in the collected frame - direction filter skipped", + direction_col, + ) + return curves + + column = curves[direction_col] + if pd.api.types.is_numeric_dtype(column): + if direction == "charge": + return curves.loc[column < 0] + if direction == "discharge": + return curves.loc[column > 0] + return curves + + return curves.loc[column == direction] + + +def sequence_plotter( + collected_curves: pd.DataFrame, + x: str = _CCOLS.capacity, + y: str = _CCOLS.potential, + z: str = _CCOLS.cycle_num, + g: str = "cell", + standard_deviation: str = None, + group: str = hdr_journal.group, + subgroup: str = hdr_journal.sub_group, + x_label: str = "Capacity", + x_unit: str = "mAh/g", + y_label: str = "Voltage", + y_unit: str = "V", + z_label: str = "Cycle", + z_unit: str = "n.", + y_label_mapper: dict = None, + nbinsx: int = 100, + histfunc: str = "avg", + histscale: str = "abs-log", + direction: str = "charge", + direction_col: str = "direction", + method: str = "fig_pr_cell", + markers: bool = False, + group_cells: bool = True, + group_legend_muting: bool = True, + backend: str = "plotly", + cycles: list = None, + facetplot: bool = False, + cols: int = 3, + palette_discrete: str = None, + palette_continuous: str = "Viridis", + palette_range: tuple = None, + height: float = None, + width: float = None, + spread: bool = False, + **kwargs, +) -> Any: + """Create a plot made up of sequences of data (voltage curves, dQ/dV, etc). + + This method contains the "common" operations done for all the sequence plots, + currently supporting filtering out the specific cycles, selecting either + dividing into subplots by cell or by cycle, and creating the (most basic) figure object. + + Args: + collected_curves (pd.DataFrame): collected data in long format. + x (str): column name for x-values. + y (str): column name for y-values. + z (str): if method is 'fig_pr_cell', column name for color (legend), else for subplot. + g (str): if method is 'fig_pr_cell', column name for subplot, else for color. + standard_deviation: str = standard deviation column (skipped if None). + group (str): column name for group. + subgroup (str): column name for subgroup. + x_label (str): x-label. + x_unit (str): x-unit (will be put in parentheses after the label). + y_label (str): y-label. + y_unit (str): y-unit (will be put in parentheses after the label). + z_label (str): z-label. + z_unit (str): z-unit (will be put in parentheses after the label). + y_label_mapper (dict): map the y-labels to something else. + nbinsx (int): number of bins to use in interpolations. + histfunc (str): aggregation method. + histscale (str): used for scaling the z-values for 2D array plots (heatmaps and similar). + direction (str): "charge", "discharge", or "both". + direction_col (str): name of columns containing information about direction ("charge" or "discharge"). + method: 'fig_pr_cell' or 'fig_pr_cycle'. + markers: set to True if you want markers. + group_cells (bool): give each cell within a group same color. + group_legend_muting (bool): if True, you can click on the legend to mute the whole group (only for plotly). + backend (str): what backend to use. + cycles: what cycles to include in the plot. + palette_discrete: palette to use for discrete color mapping. + palette_continuous: palette to use for continuous color mapping. + palette_range (tuple): range of palette to use for continuous color mapping (from 0 to 1). + facetplot (bool): square layout with group horizontally and subgroup vertically. + cols (int): number of columns for layout. + height (int): plot height. + width (int): plot width. + spread (bool): plot error-bands instead of error-bars if True. + + **kwargs: sent to backend (if `backend == "plotly"`, it will be + sent to `plotly.express` etc.) + + Returns: + figure object + """ + logging.debug("running sequence plotter") + + for k in kwargs: + logging.debug(f"keyword argument sent to the backend: {k}") + if backend not in supported_backends: + print(f"Backend '{backend}' not supported", end="") + print(f" - supported backends: {supported_backends}") + return + curves = None + + # ----------------- parsing arguments ----------------------------- + + if method == "film": + labels = { + f"{x}": f"{x_label} ({x_unit})", + f"{z}": f"{z_label} ({z_unit})", + } + plotly_arguments = dict( + x=x, + y=z, + z=y, + labels=labels, + facet_col_wrap=cols, + nbinsx=nbinsx, + histfunc=histfunc, + ) + + seaborn_arguments = dict(x=x, y=z, z=y, labels=labels, row=g, col=subgroup) + + elif method == "summary": + labels = { + f"{x}": f"{x_label} ({x_unit})", + } + plotly_arguments = dict(x=x, y=y, labels=labels, markers=markers) + seaborn_arguments = dict(x=x, y=y, markers=markers) + seaborn_arguments["labels"] = labels + + if g == "variable" and len(collected_curves[g].unique()) > 1: + plotly_arguments["facet_row"] = g + seaborn_arguments["row"] = g + if standard_deviation: + plotly_arguments["error_y"] = standard_deviation + seaborn_arguments["error_y"] = standard_deviation + + else: + labels = { + f"{x}": f"{x_label} ({x_unit})", + f"{y}": f"{y_label} ({y_unit})", + } + plotly_arguments = dict(x=x, y=y, labels=labels, facet_col_wrap=cols) + seaborn_arguments = dict(x=x, y=y, labels=labels, row=group, col=subgroup) + + if method in ["fig_pr_cell", "film"]: + group_cells = False + if method == "fig_pr_cell": + plotly_arguments["markers"] = markers + plotly_arguments["color"] = z + seaborn_arguments["hue"] = z + if facetplot: + plotly_arguments["facet_col"] = group + plotly_arguments["facet_row"] = subgroup + plotly_arguments["hover_name"] = g + else: + plotly_arguments["facet_col"] = g + + if cycles is not None: + curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] + else: + curves = collected_curves + logging.debug(f"filtered_curves:\n{curves}") + + if method == "film": + curves = _select_direction(curves, direction, direction_col) + # scaling (assuming 'y' is the "value" axis): + if histscale == "abs-log": + curves[y] = curves[y].apply(np.abs).apply(np.log) + elif histscale == "abs": + curves[y] = curves[y].apply(np.abs) + elif histscale == "norm": + curves[y] = curves[y].apply(np.abs) + + elif method == "fig_pr_cycle": + z, g = g, z + plotly_arguments["facet_col"] = g + seaborn_arguments["col"] = g + + if cycles is not None: + curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] + else: + curves = collected_curves + + if group_cells: + plotly_arguments["color"] = group + plotly_arguments["symbol"] = subgroup + seaborn_arguments["hue"] = group + seaborn_arguments["style"] = subgroup + else: + plotly_arguments["markers"] = markers + plotly_arguments["color"] = z + seaborn_arguments["hue"] = z + seaborn_arguments["style"] = z + + elif method == "summary": + if cycles is not None: + curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] + else: + curves = collected_curves + + if group_cells: + plotly_arguments["color"] = group + plotly_arguments["symbol"] = subgroup + seaborn_arguments["hue"] = group + seaborn_arguments["style"] = subgroup + else: + plotly_arguments["color"] = z + seaborn_arguments["hue"] = z + + # ----------------- individual plotting calls ----------------------------- + # TODO: move as much as possible up to the parsing of arguments + # (i.e. prepare for future refactoring) + + if backend == "plotly": + if method == "fig_pr_cell": + start, end = 0.0, 1.0 + if palette_range is not None: + start, end = palette_range + unique_cycle_numbers = curves[z].unique() + number_of_colors = len(unique_cycle_numbers) + if number_of_colors > 1: + selected_colors = px.colors.sample_colorscale( + palette_continuous, number_of_colors, low=start, high=end + ) + plotly_arguments["color_discrete_sequence"] = selected_colors + elif method == "fig_pr_cycle": + if palette_discrete is not None: + # plotly_arguments["color_discrete_sequence"] = getattr(px.colors.sequential, palette_discrete) + logging.debug( + f"palette_discrete is not implemented yet ({palette_discrete})" + ) + + elif method == "film": + number_of_colors = 10 + start, end = 0.0, 1.0 + if palette_range is not None: + start, end = palette_range + plotly_arguments["color_continuous_scale"] = px.colors.sample_colorscale( + palette_continuous, number_of_colors, low=start, high=end + ) + + elif method == "summary": + logging.info("sequence-plotter - summary plotly") + + abs_facet_row_spacing = kwargs.pop("abs_facet_row_spacing", 20) + abs_facet_col_spacing = kwargs.pop("abs_facet_col_spacing", 20) + facet_row_spacing = kwargs.pop( + "facet_row_spacing", abs_facet_row_spacing / height if height else 0.1 + ) + facet_col_spacing = kwargs.pop( + "facet_col_spacing", abs_facet_col_spacing / (width or 1000) + ) + + plotly_arguments["facet_row_spacing"] = facet_row_spacing + plotly_arguments["facet_col_spacing"] = facet_col_spacing + + logging.debug(f"{plotly_arguments=}") + logging.debug(f"{kwargs=}") + + fig = None + if method in ["fig_pr_cycle", "fig_pr_cell"]: + fig = px.line( + curves, + **plotly_arguments, + **kwargs, + ) + + if method == "fig_pr_cycle" and group_cells: + try: + fig.for_each_trace( + functools.partial( + legend_replacer, + df=curves, + group_legends=group_legend_muting, + ) + ) + if markers is not True: + fig.for_each_trace(remove_markers) + except Exception as e: + print(f"sequence_plotter - fig_pr_cycle - failed {e} [{z}]") + + elif method == "film": + fig = px.density_heatmap(curves, **plotly_arguments, **kwargs) + if histscale is None: + color_bar_txt = f"{y_label} ({y_unit})" + else: + color_bar_txt = f"{y_label} ({histscale})" + + if histscale == "hist-eq": + fig = fig.for_each_trace(lambda _x: _hist_eq(_x)) + + fig.update_layout(coloraxis_colorbar_title_text=color_bar_txt) + + elif method == "summary": + if spread: + logging.info( + "using spread is an experimental feature and might not work as expected" + ) + fig = spread_plot( + curves, + plotly_arguments=plotly_arguments, + y_label_mapper=y_label_mapper, + **kwargs, + ) + else: + # remove all kwargs that are only intended for spread_plot + _ = kwargs.pop("height_fractions_spread", None) + _ = plotly_arguments.pop("plotly_start_cell", None) + _ = plotly_arguments.pop("plotly_shared_xaxes", None) + _ = plotly_arguments.pop("plotly_vertical_spacing", None) + _ = kwargs.pop("plotly_start_cell", None) + _ = kwargs.pop("plotly_shared_xaxes", None) + _ = kwargs.pop("plotly_vertical_spacing", None) + + fig = px.line( + curves, + **plotly_arguments, + **kwargs, + ) + + if group_cells: # all cells in same group has same color + try: + fig.for_each_trace( + functools.partial( + legend_replacer, + df=curves, + group_legends=group_legend_muting, + ) + ) + if markers is not True: + fig.for_each_trace(remove_markers) + except Exception as e: + print(f"sequence_plotter - summary - failed {e} [{group}]") + + if y_label_mapper and not spread: + y_label_mapper = _plotly_y_label_cleaner(y_label_mapper) + annotations = fig.layout.annotations + if annotations: + try: + for i in range(len(annotations)): + row = i + 1 + if annotations[i].text.startswith("variable="): + variable = annotations[i].text.split("=")[1] + if variable in y_label_mapper: + v = y_label_mapper[variable] + fig.for_each_yaxis( + functools.partial(y_axis_replacer, label=v), + row=row, + ) + else: + for k, v in y_label_mapper.items(): + if annotations[i].text.endswith(k): + fig.for_each_yaxis( + functools.partial(y_axis_replacer, label=v), + row=row, + ) + break + + fig.update_annotations(text="") + + except Exception as e: + print( + f"sequence_plotter - summary - y-label mapper failed {e} [{group}]" + ) + else: + try: + fig.for_each_yaxis( + functools.partial(y_axis_replacer, label=y_label_mapper), + ) + except Exception as e: + print( + f"sequence_plotter - summary - y-label mapper - no annotations - failed {e} [{group}]" + ) + print(f"y_label_mapper: {y_label_mapper}") + print(f"annotations: {annotations}") + + else: + print(f"method '{method}' is not supported by plotly") + + return fig + + if backend == "seaborn": + number_of_data_points = len(curves) + if number_of_data_points > MAX_POINTS_SEABORN_FACET_GRID: + print( + f"WARNING! Too many data points for seaborn to plot: " + f"{number_of_data_points} > {MAX_POINTS_SEABORN_FACET_GRID}" + ) + print( + f" - Try to reduce the number of data points " + f"e.g. by selecting fewer cycles and interpolating " + f"using the `number_of_points` and `max_cycle` or `cycles_to_plot` arguments." + ) + return + + if method == "fig_pr_cell": + seaborn_arguments["height"] = kwargs.pop("height", 3) + seaborn_arguments["aspect"] = kwargs.pop("height", 1) + sns.set_theme(style="darkgrid") + x = seaborn_arguments.get("x", _CCOLS.capacity) + y = seaborn_arguments.get("y", _CCOLS.potential) + row = seaborn_arguments.get("row", hdr_journal.group) + hue = seaborn_arguments.get("hue", _CCOLS.cycle_num) + col = seaborn_arguments.get("col", hdr_journal.sub_group) + height = seaborn_arguments.get("height", 3) + aspect = seaborn_arguments.get("aspect", 1) + + if palette_discrete is not None: + seaborn_arguments["palette"] = getattr( + sns.color_palette, palette_discrete + ) + + number_of_columns = len(curves[col].unique()) + if number_of_columns > 6: + print( + f"WARNING! {number_of_columns} columns is a lot for seaborn to plot" + ) + print( + f" - consider making the plot manually (use the `.data` attribute to get the data)" + ) + + legend_items = curves[hue].unique() + number_of_legends = len(legend_items) + palette = ( + seaborn_arguments.get("palette", "viridis") + if number_of_legends > 10 + else None + ) + + g = sns.FacetGrid( + curves, + hue=hue, + row=row, + col=col, + height=height, + aspect=aspect, + palette=palette, + ) + + g.map(plt.plot, x, y) + + if number_of_legends > 10: + vmin = legend_items.min() + vmax = legend_items.max() + + sm = plt.cm.ScalarMappable( + cmap=palette, norm=plt.Normalize(vmin=vmin, vmax=vmax) + ) + cbar = g.figure.colorbar( + sm, + ax=g.figure.axes, + location="right", + extend="max", + # pad=0.05/number_of_columns, + ) + cbar.ax.set_title("Cycle") + else: + g.add_legend() + + fig = g.fig + g.set_xlabels(labels[x]) + g.set_ylabels(labels[y]) + return fig + + if method == "fig_pr_cycle": + sns.set_theme(style="darkgrid") + seaborn_arguments["height"] = 4 + seaborn_arguments["aspect"] = 3 + seaborn_arguments["linewidth"] = 2.0 + g = sns.FacetGrid( + curves, + hue=z, + height=seaborn_arguments["height"], + aspect=seaborn_arguments["aspect"], + ) + g.map(plt.plot, x, y) + fig = g.fig + g.set_xlabels(x_label) + g.set_ylabels(y_label) + g.add_legend() + return fig + + if method == "film": + sns.set_theme(style="darkgrid") + seaborn_arguments["height"] = 4 + seaborn_arguments["aspect"] = 3 + seaborn_arguments["linewidth"] = 2.0 + g = sns.FacetGrid( + curves, + hue=z, + height=seaborn_arguments["height"], + aspect=seaborn_arguments["aspect"], + ) + g.map( + sns.kdeplot, + y, + x, + fill=True, + thresh=0, + levels=100, + cmap=palette_continuous, + ) + fig = g.fig + g.set_xlabels(x_label) + g.set_ylabels(y_label) + g.add_legend() + return fig + + if method == "summary": + sns.set_theme(style="darkgrid") + seaborn_arguments["height"] = 4 + seaborn_arguments["aspect"] = 3 + seaborn_arguments["linewidth"] = 2.0 + + x = seaborn_arguments.get("x", "cycle") + y = seaborn_arguments.get("y", "mean") + hue = seaborn_arguments.get("hue", None) + + labels = seaborn_arguments.get("labels", None) + x_label = labels.get(x, x) + + std = seaborn_arguments.get("error_y", None) + marker = "o" if seaborn_arguments.get("markers", False) else None + row = seaborn_arguments.get("row", None) + + g = sns.FacetGrid( + curves, + hue=hue, + height=seaborn_arguments["height"], + aspect=seaborn_arguments["aspect"], + row=row, + ) + + if std: + g.map(plt.errorbar, x, y, std, marker=marker, elinewidth=0.5, capsize=2) + else: + g.map(plt.plot, x, y, marker=marker) + + fig = g.figure + + g.set_xlabels(x_label) + if y_label_mapper: + for i, ax in enumerate(g.axes.flat): + ax.set_ylabel(y_label_mapper[i]) + g.add_legend() + return fig + + elif backend == "matplotlib": + print(f"{backend} not implemented yet") + + elif backend == "bokeh": + print(f"{backend} not implemented yet") + + else: + print(f"{backend} not implemented yet") + + +def _cycles_plotter( + collected_curves, + cycles=None, + x=_CCOLS.capacity, + y=_CCOLS.potential, + z=_CCOLS.cycle_num, + g="cell", + standard_deviation=None, + default_title="Charge-Discharge Curves", + backend="plotly", + method="fig_pr_cell", + match_axes=True, + **kwargs, +): + """Plot charge-discharge curves. + + Args: + collected_curves(pd.DataFrame): collected data in long format. + backend (str): what backend to use. + match_axes (bool): if True, all subplots will have the same axes. + method (str): 'fig_pr_cell' or 'fig_pr_cycle'. + + **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. + + Returns: + styled figure object + """ + # --- pre-processing --- + logging.debug("picking kwargs for current level - rest goes to sequence_plotter") + title = kwargs.pop("fig_title", default_title) + width = kwargs.pop("width", 900) + height = kwargs.pop("height", None) + palette = kwargs.pop("palette", None) + legend_position = kwargs.pop("legend_position", None) + legend_title = kwargs.pop("legend_title", None) + show_legend = kwargs.pop("show_legend", None) + cols = kwargs.pop("cols", 3) + sub_fig_min_height = kwargs.pop("sub_fig_min_height", 200) + figure_border_height = kwargs.pop("figure_border_height", 100) + # kwargs from default `BatchCollector.render` method not used by `sequence_plotter`: + journal = kwargs.pop("journal", None) + units = kwargs.pop("units", None) + + if palette is not None: + kwargs["palette_continuous"] = palette + kwargs["palette_discrete"] = palette + + if legend_title is None: + if method == "fig_pr_cell": + legend_title = "Cycle" + else: + legend_title = "Cell" + + no_cols = cols + + if method in ["fig_pr_cell", "film"]: + number_of_figs = len(collected_curves["cell"].unique()) + + elif method == "fig_pr_cycle": + if cycles is not None: + number_of_figs = len(cycles) + else: + number_of_figs = len(collected_curves[_CCOLS.cycle_num].unique()) + elif method == "summary": + number_of_figs = len(collected_curves["variable"].unique()) + sub_fig_min_height = 300 + else: + number_of_figs = 1 + + no_rows = math.ceil(number_of_figs / no_cols) + + if not height: + height = figure_border_height + no_rows * sub_fig_min_height + + fig = sequence_plotter( + collected_curves, + x=x, + y=y, + z=z, + g=g, + standard_deviation=standard_deviation, + backend=backend, + method=method, + cols=cols, + cycles=cycles, + width=width, + height=height, + **kwargs, + ) + if fig is None: + print("Could not create figure!") + return + + # Rendering: + if backend == "plotly": + template = f"{PLOTLY_BASE_TEMPLATE}+{method}" + + legend_orientation = "v" + if legend_position == "bottom": + legend_orientation = "h" + + legend_dict = { + "title": legend_title, + "orientation": legend_orientation, + } + title_dict = { + "text": title, + } + + fig.update_layout( + template=template, + title=title_dict, + legend=legend_dict, + showlegend=show_legend, + height=height, + width=width, + ) + if not match_axes: + fig.update_yaxes(matches=None) + fig.update_xaxes(matches=None) + + return fig + + +def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **kwargs): + """Plot summaries (value vs cycle number). + + Assuming data as pandas.DataFrame with either + 1) long format (where variables, for example charge capacity, are in the column "variable") or + 2) mixed long and wide format where the variables are own columns. + """ + + # start_cell is used to determine the starting cell for the subplots (plotly) + start_cell = kwargs.pop("start_cell", "bottom-left") + + col_headers = collected_curves.columns.to_list() + + # need to manually update this if new columns are added to collected_curves that should not be plotted: + not_available_for_plotting = [hdr_journal.label, hdr_journal.group_label, hdr_journal.selected] + + possible_id_vars = [ + "cell", + "cycle", + "equivalent_cycle", + "value", + "mean", + "std", + hdr_journal.group, + hdr_journal.sub_group, + ] + id_vars = [] + for n in possible_id_vars: + if n in col_headers: + col_headers.remove(n) + id_vars.append(n) + for n in not_available_for_plotting: + if n in col_headers: + col_headers.remove(n) + + if "variable" not in col_headers: + collected_curves = collected_curves.melt( + id_vars=id_vars, value_vars=col_headers + ) + + normalize_cycles = True if "equivalent_cycle" in id_vars else False + group_it = False if hdr_journal.group in id_vars else True + + cols = kwargs.pop("cols", 1) + + z = "cell" + g = "variable" + + if normalize_cycles: + x = "equivalent_cycle" + x_label = "Equivalent Cycle" + x_unit = "cum/nom.cap." + else: + x = "cycle" + x_label = "Cycle" + x_unit = "n." + + if group_it: + group_cells = False + y = "mean" + standard_deviation = "std" + + else: + y = "value" + standard_deviation = None + group_cells = kwargs.pop("group_cells", True) + + units = kwargs.pop("units", None) + label_mapper = { + f"{y}": None, + } + # order the variables by a given order: + order_variables = kwargs.pop("order_variables", None) + if order_variables: + collected_curves[g] = collected_curves[g].astype( + pd.CategoricalDtype(categories=order_variables, ordered=True) + ) + collected_curves = collected_curves.sort_values(by=[g, z, x]) + + if units: + label_mapper[y] = {} + variables = list(collected_curves[g].unique()) + for v in variables: + # unit label + u_sub = None + if v.endswith("_areal") or v.endswith("_areal_cv"): + u_sub = units["cellpy_units"].specific_areal + elif v.endswith("_gravimetric") or v.endswith("_gravimetric_cv"): + u_sub = units["cellpy_units"].specific_gravimetric + elif v.endswith("_volumetric") or v.endswith("_volumetric_cv"): + u_sub = units["cellpy_units"].specific_volumetric + + u_top = None + if "_capacity" in v: + u_top = units["cellpy_units"].charge + if "_norm" in v: + u_top = "normalized" + if v == "coulombic_efficiency": + u_top = "%" + + u = u_top or "Value" + + # variable label + v2 = v.split("_") + if u_sub: + u_sub = u_sub.replace("**", "") + u = f"{u}/{u_sub}" + if v2[-1] == "cv": + v2 = v2[:-2] + v2.append("cv") + else: + v2 = v2[:-1] + v2 = " ".join(v2).title() + + if v2.endswith("Cv"): + v2 = v2.replace("Cv", "CV") + + label_mapper[y][v] = f"{v2} ({u})" + + # TODO: need to refactor and fix how the classes are created so that leftover kwargs are not sent to the backend + # (for example if another collector is used and registers a kwarg without popping it) + + _ = kwargs.pop("method", None) # also set in BatchCyclesCollector + height_fractions = kwargs.pop("height_fractions", []) + + fig = _cycles_plotter( + collected_curves, + x=x, + y=y, + z=z, + g=g, + standard_deviation=standard_deviation, + x_label=x_label, + x_unit=x_unit, + y_label_mapper=label_mapper[y], + group_cells=group_cells, + default_title="Summary Plot", + backend=backend, + method="summary", + cycles=cycles_to_plot, + cols=cols, + **kwargs, + ) + + if backend == "plotly": + # TODO: implement having different heights of the subplots + + if len(height_fractions) > 0: + # Determine number of rows in the original figure + print("THIS IS EXPERIMENTAL") + number_of_rows = len([key for key in fig.layout if key.startswith("yaxis")]) + if number_of_rows == 0: + number_of_rows = 1 # Default to 1 if no y-axes found + + # Only proceed if height_fractions matches the number of rows + if len(height_fractions) != number_of_rows: + print( + f"Warning: height_fractions length ({len(height_fractions)}) does not match number of rows ({number_of_rows}). Ignoring height_fractions." + ) + else: + # Update subplot heights using make_subplots parameters + from plotly.subplots import make_subplots + + # Get current figure data and layout properties + current_data = fig.data + current_layout = fig.layout + + # Create new figure with custom row heights + new_fig = make_subplots( + rows=number_of_rows, + cols=1, + start_cell=start_cell, + shared_xaxes=True, + row_heights=height_fractions[::-1], + vertical_spacing=0.02, + subplot_titles=[ann.text for ann in current_layout.annotations] + if current_layout.annotations + else None, + ) + + new_height_fractions = {} + for key in new_fig.layout: + if key.startswith("yaxis"): + new_height_fractions[key] = new_fig.layout[key].domain + + # Add traces from original figure + for trace in current_data: + new_fig.add_trace(trace) + + # Update layout properties from original figure (including theme) + new_fig.update_layout(current_layout) + for key in new_height_fractions: + new_fig.layout[key].domain = new_height_fractions[key] + + fig = new_fig + # Preserve x-axis linking and only show labels on bottom row with small gaps + fig.update_xaxes(matches="x") + fig.update_yaxes(matches=None, showticklabels=True) + + # Only show x-axis labels on the bottom subplot (not needed anymore?) + # for i in range(1, len(height_fractions)): + # fig.update_xaxes(showticklabels=False, row=i, col=1) + + return fig + if backend == "seaborn": + print("using seaborn (experimental feature)") + return fig + if backend == "matplotlib": + print("using matplotlib (experimental feature)") + return fig + if backend == "bokeh": + print("using bokeh (experimental feature)") + return fig + + +def cycles_plotter( + collected_curves, + cycles_to_plot=None, + backend="plotly", + method="fig_pr_cell", + x_unit="mAh/g", + y_unit="V", + **kwargs, +): + """Plot charge-discharge curves. + + Args: + collected_curves(pd.DataFrame): collected data in long format. + cycles_to_plot (list): cycles to plot + backend (str): what backend to use. + method (str): 'fig_pr_cell' or 'fig_pr_cycle'. + x_unit (str): unit for x-axis. + y_unit (str): unit for y-axis. + + **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. + + Returns: + styled figure object + """ + + if cycles_to_plot is not None: + unique_cycles = list(collected_curves.cycle.unique()) + if len(unique_cycles) > 50: + print(f"Too many cycles - setting it to default {DEFAULT_CYCLES}") + cycles_to_plot = DEFAULT_CYCLES + + return _cycles_plotter( + collected_curves, + x=_CCOLS.capacity, + y=_CCOLS.potential, + z=_CCOLS.cycle_num, + g="cell", + x_unit=x_unit, + y_unit=y_unit, + default_title="Charge-Discharge Curves", + backend=backend, + method=method, + cycles=cycles_to_plot, + **kwargs, + ) + + +def ica_plotter( + collected_curves, + cycles_to_plot=None, + backend="plotly", + method="fig_pr_cell", + direction="charge", + **kwargs, +): + """Plot charge-discharge curves. + + Args: + collected_curves(pd.DataFrame): collected data in long format. + cycles_to_plot (list): cycles to plot + backend (str): what backend to use. + method (str): 'fig_pr_cell' or 'fig_pr_cycle' or 'film'. + direction (str): 'charge' or 'discharge'. + + **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. + + Returns: + styled figure object + """ + + if cycles_to_plot is None: + unique_cycles = list(collected_curves.cycle.unique()) + max_cycle = max(unique_cycles) + if len(unique_cycles) > 50: + cycles_to_plot = DEFAULT_CYCLES + max_cycle = max(cycles_to_plot) + else: + max_cycle = max(cycles_to_plot) + + if direction not in ["charge", "discharge"]: + print(f"direction='{direction}' not allowed - setting it to 'charge'") + direction = "charge" + if method == "film": + kwargs["range_y"] = kwargs.pop("range_y", None) or (1, max_cycle) + + return _cycles_plotter( + collected_curves, + x="voltage", + y="dqdv", + z="cycle", + g="cell", + x_label="Voltage", + x_unit="V", + y_label="dQ/dV", + y_unit="mAh/g/V.", + default_title=f"Incremental Analysis Plots", + direction=direction, + backend=backend, + method=method, + cycles=cycles_to_plot, + **kwargs, + ) + + +def histogram_equalization(image: np.array) -> np.array: + """Perform histogram equalization on a numpy array.""" + # from http://www.janeriksolem.net/histogram-equalization-with-python-and.html + number_bins = 256 + scale = 100 + image[np.isnan(image)] = 0.0 + image_histogram, bins = np.histogram(image.flatten(), number_bins, density=True) + cdf = image_histogram.cumsum() # cumulative distribution function + cdf = (scale - 1) * cdf / cdf[-1] # normalize + # use linear interpolation of cdf to find new pixel values + image_equalized = np.interp(image.flatten(), bins[:-1], cdf) + + return image_equalized.reshape(image.shape) + + + + +# --------------------------------------------------------------------------- +# Public orchestrator (layout=/kind= → FigureSpec → backend) +# --------------------------------------------------------------------------- + +_METHOD_TO_LAYOUT = { + "fig_pr_cell": "per_cell", + "fig_pr_cycle": "per_cycle", + "film": "per_cell", + "summary": "summary", +} + +_LAYOUT_TO_METHOD = { + "per_cell": "fig_pr_cell", + "per_cycle": "fig_pr_cycle", + "summary": "summary", +} + + +def resolve_collected_layout_kind( + *, + layout: Optional[str] = None, + kind: Optional[str] = None, + method: Optional[str] = None, + plot_type: Optional[str] = None, + spread: bool = False, +) -> tuple[str, str, str]: + """Map legacy ``method``/``plot_type``/``spread`` to ``(layout, kind, method)``. + + Returns: + layout: ``per_cell`` | ``per_cycle`` | ``summary`` + kind: ``line`` | ``film`` | ``spread`` + method: legacy template/method string still understood by renderers + """ + if method is None and plot_type is not None: + method = plot_type + if kind is None: + if spread: + kind = "spread" + elif method == "film": + kind = "film" + else: + kind = "line" + if layout is None: + if method in _METHOD_TO_LAYOUT: + layout = _METHOD_TO_LAYOUT[method] + elif kind == "film": + layout = "per_cell" + else: + layout = "per_cell" + if method is None: + if kind == "film": + method = "film" + else: + method = _LAYOUT_TO_METHOD.get(layout, "fig_pr_cell") + if kind == "spread": + # spread is a summary rendering mode + if layout not in ("summary", "per_cell"): + layout = "summary" + if method not in ("summary", "film", "fig_pr_cell", "fig_pr_cycle"): + method = "summary" + return layout, kind, method + + +def render_collected(frame: Any, spec: FigureSpec, *, backend_override: Optional[str] = None) -> Any: + """Dispatch a collected-frame ``FigureSpec`` to the legacy layout engines.""" + extras = dict(spec.extras or {}) + family_kind = extras.get("family_kind") or "cycles" + method = extras.get("method") or "fig_pr_cell" + collected_kind = extras.get("collected_kind") or "line" + opts = dict(extras.get("render_opts") or {}) + backend = backend_override or extras.get("backend") or "plotly" + + if collected_kind == "spread": + opts["spread"] = True + if collected_kind == "film": + method = "film" + + # Do not let a resolved layout method override summary_plotter's forced + # method="summary" via **kwargs. + opts.pop("method", None) + opts.pop("plot_type", None) + + if family_kind == "summary": + return summary_plotter(frame, backend=backend, **opts) + if family_kind == "ica": + return ica_plotter(frame, backend=backend, method=method, **opts) + if family_kind == "cycles": + return cycles_plotter(frame, backend=backend, method=method, **opts) + return sequence_plotter(frame, backend=backend, method=method, **opts) + + +def collected_plot( + frame: Any, + *, + family_kind: str = "cycles", + layout: Optional[str] = None, + kind: Optional[str] = None, + backend: str = "plotly", + method: Optional[str] = None, + plot_type: Optional[str] = None, + spread: bool = False, + **opts: Any, +) -> Any: + """Plot an already-collected tidy multi-cell frame (#657). + + Args: + frame: long/tidy frame with ``cell`` / ``group`` / ``sub_group`` as needed. + family_kind: ``summary`` | ``cycles`` | ``ica`` (selects column defaults). + layout: ``per_cell`` | ``per_cycle`` | ``summary``. + kind: ``line`` | ``film`` | ``spread``. + backend: ``plotly`` (primary) or ``seaborn`` / ``matplotlib`` (best-effort). + method / plot_type: legacy collector knobs (mapped to layout/kind). + spread: legacy flag → ``kind="spread"``. + **opts: forwarded to the collected renderers (cycles, labels, sizes, …). + + Returns: + Backend-native figure object. + """ + from cellpy._deprecation import warn_once + from cellpy.plotting.backends import get_backend + + layout, kind, method = resolve_collected_layout_kind( + layout=layout, + kind=kind, + method=method, + plot_type=plot_type, + spread=spread or bool(opts.get("spread")), + ) + opts = dict(opts) + opts.pop("spread", None) + if kind == "spread": + opts["spread"] = True + + # Ensure collector templates exist before plotly render. + if backend == "plotly" and pio is not None: + pio.templates.default = PLOTLY_BASE_TEMPLATE + theme.make_collector_templates() + + backend_key = (backend or "plotly").strip().lower() + if backend_key == "matplotlib": + warn_once( + "collected_plot: backend='matplotlib' uses the collectors seaborn " + "layout path (best-effort parity); prefer backend='plotly'.", + stacklevel=2, + ) + backend_key = "seaborn" + + spec = FigureSpec( + title=opts.get("fig_title"), + extras={ + "kind": "collected", + "family_kind": family_kind, + "layout": layout, + "collected_kind": kind, + "method": method, + "backend": backend_key, + "render_opts": opts, + }, + ) + + if backend_key == "seaborn": + # Keep the historical seaborn branch without forcing get_backend("matplotlib") + # into the single-cell summary path. + return render_collected(frame, spec, backend_override="seaborn") + + return get_backend(backend_key).render(frame, spec) + + diff --git a/cellpy/plotting/context.py b/cellpy/plotting/context.py index f7d278e9..c0c42bf1 100644 --- a/cellpy/plotting/context.py +++ b/cellpy/plotting/context.py @@ -1,13 +1,13 @@ -"""Source adapters for prepare → spec → render (#638). +"""Source adapters for prepare → spec → render (#638 / #657). The only code in ``cellpy.plotting`` that should reach into ``CellpyCell`` / -``Batch`` objects. BatchContext arrives with the collectors rebase. +``Batch`` objects — or wrap an already-collected tidy frame. """ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Optional @dataclass @@ -40,12 +40,46 @@ def make_summary(self, **kwargs: Any) -> Any: return self.cell.make_summary(**kwargs) +@dataclass +class FrameContext: + """Already-collected tidy multi-cell frame for collected plotting (#657). + + Collectors own collection; plotting only needs the frame plus light metadata + (units / journal / family kind). + """ + + frame: Any + family_kind: str = "cycles" + units: Any = None + journal: Any = None + + +def from_frame( + frame: Any, + *, + family_kind: str = "cycles", + units: Any = None, + journal: Any = None, +) -> FrameContext: + """Wrap a collected tidy frame as a :class:`FrameContext`.""" + if isinstance(frame, FrameContext): + return frame + return FrameContext( + frame=frame, family_kind=family_kind, units=units, journal=journal + ) + + 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. + Single-cell inputs become :class:`CellContext`. Collected frames should use + :func:`from_frame` / :func:`collected_plot` instead (#657). """ if isinstance(source, CellContext): return source + if isinstance(source, FrameContext): + raise TypeError( + "FrameContext is not a CellContext; use cellpy.plotting.collected_plot " + "for collected multi-cell frames (#657)" + ) return CellContext(cell=source) diff --git a/cellpy/utils/collectors.py b/cellpy/utils/collectors.py index 82080c28..6e0f05fa 100644 --- a/cellpy/utils/collectors.py +++ b/cellpy/utils/collectors.py @@ -15,9 +15,13 @@ import warnings import pandas as pd -import matplotlib.pyplot as plt import numpy as np +try: + import matplotlib.pyplot as plt +except ImportError: # pragma: no cover - optional for non-plot imports + plt = None + import cellpy from cellpycore.config import CurveCols @@ -44,23 +48,21 @@ ) from cellpy.plotting.labels import legend_replacer, remove_markers # noqa: F401 from cellpy.plotting import theme +from cellpy.plotting.collected import collected_plot, _select_direction # noqa: F401 hdr_journal = get_headers_journal() supported_backends = [] try: - import plotly - import plotly.express as px - import plotly.io as pio - import plotly.graph_objects as go + import plotly # noqa: F401 supported_backends.append("plotly") except ImportError: print("WARNING: plotly not installed") try: - import seaborn as sns + import seaborn # noqa: F401 supported_backends.append("seaborn") except ImportError: @@ -208,11 +210,14 @@ class BatchCollector: _default_data_collector_arguments = {} _default_plotter_arguments = {} + # Drawing family for cellpy.plotting.collected_plot (#657). + family_kind: str = "cycles" + def __init__( self, b, data_collector, - plotter, + plotter=None, collector_name=None, name=None, nick=None, @@ -223,6 +228,7 @@ def __init__( data_collector_arguments: dict = None, plotter_arguments: dict = None, experimental: bool = False, + family_kind: str = None, **kwargs, ): """Update both the collected data and the plot(s). @@ -230,7 +236,8 @@ def __init__( Args: b (cellpy.utils.Batch): the batch object. data_collector (callable): method that collects the data. - plotter (callable): method that crates the plots. + plotter (callable): optional custom plotter; default ``None`` uses + :func:`cellpy.plotting.collected_plot` (#657). collector_name (str): name of collector. name (str or bool): name used for auto-generating filenames etc. autorun (bool): run collector and plotter immediately if True. @@ -240,6 +247,7 @@ def __init__( elevated_plotter_arguments (dict): arguments picked up by the child class' initializer. data_collector_arguments (dict): keyword arguments sent to the data collector. plotter_arguments (dict): keyword arguments sent to the plotter. + family_kind (str): ``summary`` | ``cycles`` | ``ica`` for collected_plot. update_name (bool): update the name (using automatic name generation) based on new settings. **kwargs: set Collector attributes. @@ -250,6 +258,8 @@ def __init__( self.plotter = plotter self.nick = nick self.experimental = experimental + if family_kind is not None: + self.family_kind = family_kind if backend != "plotly" and not self.experimental: print(f"{backend=}") print("WARNING: only plotly is supported at the moment") @@ -286,7 +296,14 @@ def __init__( @staticmethod def _set_plotly_templates(): - pio.templates.default = PLOTLY_BASE_TEMPLATE + # Templates live in cellpy.plotting.theme; collected_plot also ensures + # registration on first draw (#657). Keep a lazy hook for autorun. + try: + import plotly.io as _pio + + _pio.templates.default = PLOTLY_BASE_TEMPLATE + except ImportError: + pass theme.make_collector_templates() @property @@ -419,17 +436,31 @@ def generate_name(self): return name def render(self, **kwargs): - """Render the figure.""" + """Render the figure via ``cellpy.plotting.collected_plot`` (#657).""" kwargs = {**self.plotter_arguments, **kwargs} - self.figure = self.plotter( + if self.plotter is not None: + self.figure = self.plotter( + self.data, + backend=self.backend, + journal=self.b.journal, + units=self.units, + **kwargs, + ) + return + self.figure = collected_plot( self.data, + family_kind=self.family_kind, backend=self.backend, journal=self.b.journal, units=self.units, **kwargs, ) + def plot(self, **kwargs): + """Alias for :meth:`render` (plotting redesign §3.3 / #657).""" + return self.render(**kwargs) + def _parse_elevated_arguments( self, data_collector_arguments: dict = None, plotter_arguments: dict = None ): @@ -1073,9 +1104,9 @@ def __init__( super().__init__( b, backend=backend, - plotter=summary_plotter, data_collector=summary_collector, collector_name="summary", + family_kind="summary", elevated_data_collector_arguments=elevated_data_collector_arguments, elevated_plotter_arguments=elevated_plotter_arguments, csv_layout=csv_layout, @@ -1254,7 +1285,7 @@ def __init__( super().__init__( b, - plotter=ica_plotter, + family_kind="ica", data_collector=ica_collector, collector_name="ica", backend=backend, @@ -1332,7 +1363,7 @@ def __init__( - "forth-and-forth" - discharge (or charge) also starts at 0. data_collector_arguments (dict) - arguments transferred to the to `CellpyCell.get_cap`. - plotter_arguments (dict) - arguments transferred to `cycles_plotter`. + plotter_arguments (dict) - arguments transferred to ``collected_plot``. cycles (list): select these cycles (elevated data collector argument). max_cycle (int): drop all cycles above this value (elevated data collector argument). rate (float): filter on rate (C-rate) (elevated data collector argument). @@ -1389,7 +1420,7 @@ def __init__( super().__init__( b, - plotter=cycles_plotter, + family_kind="cycles", data_collector=cycles_collector, backend=backend, collector_name="cycles", @@ -1686,1245 +1717,9 @@ def ica_collector( return collected_curves -# plotter functions (consider moving to plotutils) - - - - -def _hist_eq(trace): - z = histogram_equalization(trace.z) - trace.update(z=z) - return trace - - -def y_axis_replacer(ax, label): - """Replace y-axis label in matplotlib plots.""" - if isinstance(label, dict): - _label = label.get(ax.title.text, None) - if _label is None: - _label = list(label.values())[0] - ax.update(title_text=_label) - else: - ax.update(title_text=label) - return ax - - - - -def _plotly_y_label_cleaner(y_label_mapper, split_at=20): - """Clean up the y-label mapper for plotly. - - The y-label mapper is a dictionary that maps the variable name to the y-label. The y-labels are - expected to be in the form of "Variable Name (unit)". If the y-label is too long, it is - split into multiple lines. - This is done to avoid the y-labels from being too long and wrapping around. - - Discharge Capacity Retention Gravimetric Norm (%) should become: - Discharge Capacity
Retention Gravimetric Norm
(%) - - Args: - y_label_mapper (dict): the y-label mapper. - - Returns: - dict: the cleaned up y-label mapper. - - """ - - new_y_label_mapper = {} - for k, v in y_label_mapper.items(): - if len(v) > split_at: - # First split on " (" pattern - v = "
(".join(v.split(" (")) - - # Then check if any resulting line is still too long and split on spaces - lines = v.split("
") - final_lines = [] - for line in lines: - if len(line) > split_at and " " in line: - # Split long lines on spaces - words = line.split(" ") - current_line = "" - for word in words: - if len(current_line + " " + word) > split_at and current_line: - final_lines.append(current_line) - current_line = word - else: - if current_line: - current_line += " " + word - else: - current_line = word - if current_line: - final_lines.append(current_line) - else: - final_lines.append(line) - v = "
".join(final_lines) - new_y_label_mapper[k] = v - return new_y_label_mapper - - -def spread_plot(curves, plotly_arguments=None, y_label_mapper=None, **kwargs): - """Create a spread plot (error-bands instead of error-bars). - - This is an experimental feature that is not yet fully tested. It uses make_subplots to create the figure, - and then adds the traces one by one. This methodology will eventually replace the use of plotly.express - for all the summary plots. - - """ - from plotly.subplots import make_subplots - - if y_label_mapper is None: - y_label_mapper = {} - else: - y_label_mapper = _plotly_y_label_cleaner(y_label_mapper) - - selected_variables = curves["variable"].unique() - number_of_rows = len(selected_variables) - # TODO: change this (only temporary fix to allow height fractions to be set by spread_plot) - height_fractions = kwargs.get( - "height_fractions_spread", [1 / number_of_rows] * number_of_rows - ) - - colors = plotly.colors.qualitative.Plotly - opacity = 0.2 - color_list = [] - for color in colors: - color_rgb = plotly.colors.hex_to_rgb(color) - color_rgb_main = f"rgb({color_rgb[0]}, {color_rgb[1]}, {color_rgb[2]})" - color_rgba_spread = ( - f"rgba({color_rgb[0]}, {color_rgb[1]}, {color_rgb[2]}, {opacity})" - ) - color_list.append((color_rgb_main, color_rgba_spread)) - - if plotly_arguments.get("markers"): - mode = "lines+markers" - else: - mode = "lines" - - g = curves.groupby("cell") - fig = make_subplots( - rows=number_of_rows, - cols=1, - start_cell=plotly_arguments.get("plotly_start_cell", "top-left"), - shared_xaxes=plotly_arguments.get("plotly_shared_xaxes", True), - row_heights=height_fractions, - vertical_spacing=plotly_arguments.get("plotly_vertical_spacing", 0.01), - ) - y_labels = {} - for i, (cell, data) in enumerate(g): - color = color_list[i % len(color_list)] - - for row_number, variable in enumerate(selected_variables): - y_label = y_label_mapper.get(variable, variable) - y_labels[row_number] = y_label - if row_number == 0: - show_legend = True - else: - show_legend = False - sub_data = data[data["variable"] == variable] - fig.add_trace( - go.Scatter( - name=cell, - x=sub_data["cycle"], - y=sub_data["mean"], - mode=mode, - line=dict(color=color[0]), - legendgroup=cell, - legendgrouptitle=None, - showlegend=show_legend, - ), - row=row_number + 1, - col=1, - ) - fig.add_trace( - go.Scatter( - name=f"Upper Bound {cell}", - x=sub_data["cycle"], - y=sub_data["mean"] + sub_data["std"], - mode="lines", - marker=dict( - color=color[1], - ), - line=dict(width=0), - showlegend=False, - legendgroup=cell, - ), - row=row_number + 1, - col=1, - ) - fig.add_trace( - go.Scatter( - name=f"Lower Bound {cell}", - x=sub_data["cycle"], - y=sub_data["mean"] - sub_data["std"], - mode="lines", - marker=dict( - color=color[1], - ), - line=dict(width=0), - fillcolor=color[1], - fill="tonexty", - showlegend=False, - legendgroup=cell, - ), - row=row_number + 1, - col=1, - ) - for row_number, y_label in y_labels.items(): - fig.update_yaxes(title_text=y_label, row=row_number + 1, col=1) - - fig.update_layout(legend_tracegroupgap=0) - # fig.update_layout(hovermode="x") - - if labels := plotly_arguments.get("labels"): - fig.update_xaxes(title=labels.get("cycle", None), row=number_of_rows) - - # Hack to remove the x-axis title that appears on the top of the plot: - # if number_of_rows > 1: - # fig.update_layout(xaxis_title=None) - - if hover_mode := kwargs.pop("hovermode", None): - fig.update_layout(hovermode=hover_mode) - - return fig - - -def _select_direction(curves, direction, direction_col="direction"): - """Select one direction from a collected curve frame. - - Handles both direction encodings that reach the plotters: - - - The specced ICA frame (#566) spells direction out ("charge" / - "discharge"), **cell-centric** per decision #591. - - Frames straight from ``get_cap(categorical_column=True)`` still carry - the raw ±1 half-cycle code. For those the historical mapping is kept - (-1 selected as "charge") so non-ICA film plots are unchanged; the code - is positional, and relabelling it needs the cell's cycle_mode, which a - collected frame no longer knows. - """ - if direction_col not in curves.columns: - logging.debug( - "no %r column in the collected frame - direction filter skipped", - direction_col, - ) - return curves - - column = curves[direction_col] - if pd.api.types.is_numeric_dtype(column): - if direction == "charge": - return curves.loc[column < 0] - if direction == "discharge": - return curves.loc[column > 0] - return curves - - return curves.loc[column == direction] - - -def sequence_plotter( - collected_curves: pd.DataFrame, - x: str = _CCOLS.capacity, - y: str = _CCOLS.potential, - z: str = _CCOLS.cycle_num, - g: str = "cell", - standard_deviation: str = None, - group: str = hdr_journal.group, - subgroup: str = hdr_journal.sub_group, - x_label: str = "Capacity", - x_unit: str = "mAh/g", - y_label: str = "Voltage", - y_unit: str = "V", - z_label: str = "Cycle", - z_unit: str = "n.", - y_label_mapper: dict = None, - nbinsx: int = 100, - histfunc: str = "avg", - histscale: str = "abs-log", - direction: str = "charge", - direction_col: str = "direction", - method: str = "fig_pr_cell", - markers: bool = False, - group_cells: bool = True, - group_legend_muting: bool = True, - backend: str = "plotly", - cycles: list = None, - facetplot: bool = False, - cols: int = 3, - palette_discrete: str = None, - palette_continuous: str = "Viridis", - palette_range: tuple = None, - height: float = None, - width: float = None, - spread: bool = False, - **kwargs, -) -> Any: - """Create a plot made up of sequences of data (voltage curves, dQ/dV, etc). - - This method contains the "common" operations done for all the sequence plots, - currently supporting filtering out the specific cycles, selecting either - dividing into subplots by cell or by cycle, and creating the (most basic) figure object. - - Args: - collected_curves (pd.DataFrame): collected data in long format. - x (str): column name for x-values. - y (str): column name for y-values. - z (str): if method is 'fig_pr_cell', column name for color (legend), else for subplot. - g (str): if method is 'fig_pr_cell', column name for subplot, else for color. - standard_deviation: str = standard deviation column (skipped if None). - group (str): column name for group. - subgroup (str): column name for subgroup. - x_label (str): x-label. - x_unit (str): x-unit (will be put in parentheses after the label). - y_label (str): y-label. - y_unit (str): y-unit (will be put in parentheses after the label). - z_label (str): z-label. - z_unit (str): z-unit (will be put in parentheses after the label). - y_label_mapper (dict): map the y-labels to something else. - nbinsx (int): number of bins to use in interpolations. - histfunc (str): aggregation method. - histscale (str): used for scaling the z-values for 2D array plots (heatmaps and similar). - direction (str): "charge", "discharge", or "both". - direction_col (str): name of columns containing information about direction ("charge" or "discharge"). - method: 'fig_pr_cell' or 'fig_pr_cycle'. - markers: set to True if you want markers. - group_cells (bool): give each cell within a group same color. - group_legend_muting (bool): if True, you can click on the legend to mute the whole group (only for plotly). - backend (str): what backend to use. - cycles: what cycles to include in the plot. - palette_discrete: palette to use for discrete color mapping. - palette_continuous: palette to use for continuous color mapping. - palette_range (tuple): range of palette to use for continuous color mapping (from 0 to 1). - facetplot (bool): square layout with group horizontally and subgroup vertically. - cols (int): number of columns for layout. - height (int): plot height. - width (int): plot width. - spread (bool): plot error-bands instead of error-bars if True. - - **kwargs: sent to backend (if `backend == "plotly"`, it will be - sent to `plotly.express` etc.) - - Returns: - figure object - """ - logging.debug("running sequence plotter") - - for k in kwargs: - logging.debug(f"keyword argument sent to the backend: {k}") - if backend not in supported_backends: - print(f"Backend '{backend}' not supported", end="") - print(f" - supported backends: {supported_backends}") - return - curves = None - - # ----------------- parsing arguments ----------------------------- - - if method == "film": - labels = { - f"{x}": f"{x_label} ({x_unit})", - f"{z}": f"{z_label} ({z_unit})", - } - plotly_arguments = dict( - x=x, - y=z, - z=y, - labels=labels, - facet_col_wrap=cols, - nbinsx=nbinsx, - histfunc=histfunc, - ) - - seaborn_arguments = dict(x=x, y=z, z=y, labels=labels, row=g, col=subgroup) - - elif method == "summary": - labels = { - f"{x}": f"{x_label} ({x_unit})", - } - plotly_arguments = dict(x=x, y=y, labels=labels, markers=markers) - seaborn_arguments = dict(x=x, y=y, markers=markers) - seaborn_arguments["labels"] = labels - - if g == "variable" and len(collected_curves[g].unique()) > 1: - plotly_arguments["facet_row"] = g - seaborn_arguments["row"] = g - if standard_deviation: - plotly_arguments["error_y"] = standard_deviation - seaborn_arguments["error_y"] = standard_deviation - - else: - labels = { - f"{x}": f"{x_label} ({x_unit})", - f"{y}": f"{y_label} ({y_unit})", - } - plotly_arguments = dict(x=x, y=y, labels=labels, facet_col_wrap=cols) - seaborn_arguments = dict(x=x, y=y, labels=labels, row=group, col=subgroup) - - if method in ["fig_pr_cell", "film"]: - group_cells = False - if method == "fig_pr_cell": - plotly_arguments["markers"] = markers - plotly_arguments["color"] = z - seaborn_arguments["hue"] = z - if facetplot: - plotly_arguments["facet_col"] = group - plotly_arguments["facet_row"] = subgroup - plotly_arguments["hover_name"] = g - else: - plotly_arguments["facet_col"] = g - - if cycles is not None: - curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] - else: - curves = collected_curves - logging.debug(f"filtered_curves:\n{curves}") - - if method == "film": - curves = _select_direction(curves, direction, direction_col) - # scaling (assuming 'y' is the "value" axis): - if histscale == "abs-log": - curves[y] = curves[y].apply(np.abs).apply(np.log) - elif histscale == "abs": - curves[y] = curves[y].apply(np.abs) - elif histscale == "norm": - curves[y] = curves[y].apply(np.abs) - - elif method == "fig_pr_cycle": - z, g = g, z - plotly_arguments["facet_col"] = g - seaborn_arguments["col"] = g - - if cycles is not None: - curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] - else: - curves = collected_curves - - if group_cells: - plotly_arguments["color"] = group - plotly_arguments["symbol"] = subgroup - seaborn_arguments["hue"] = group - seaborn_arguments["style"] = subgroup - else: - plotly_arguments["markers"] = markers - plotly_arguments["color"] = z - seaborn_arguments["hue"] = z - seaborn_arguments["style"] = z - - elif method == "summary": - if cycles is not None: - curves = collected_curves.loc[collected_curves.cycle.isin(cycles), :] - else: - curves = collected_curves - - if group_cells: - plotly_arguments["color"] = group - plotly_arguments["symbol"] = subgroup - seaborn_arguments["hue"] = group - seaborn_arguments["style"] = subgroup - else: - plotly_arguments["color"] = z - seaborn_arguments["hue"] = z - - # ----------------- individual plotting calls ----------------------------- - # TODO: move as much as possible up to the parsing of arguments - # (i.e. prepare for future refactoring) - - if backend == "plotly": - if method == "fig_pr_cell": - start, end = 0.0, 1.0 - if palette_range is not None: - start, end = palette_range - unique_cycle_numbers = curves[z].unique() - number_of_colors = len(unique_cycle_numbers) - if number_of_colors > 1: - selected_colors = px.colors.sample_colorscale( - palette_continuous, number_of_colors, low=start, high=end - ) - plotly_arguments["color_discrete_sequence"] = selected_colors - elif method == "fig_pr_cycle": - if palette_discrete is not None: - # plotly_arguments["color_discrete_sequence"] = getattr(px.colors.sequential, palette_discrete) - logging.debug( - f"palette_discrete is not implemented yet ({palette_discrete})" - ) - - elif method == "film": - number_of_colors = 10 - start, end = 0.0, 1.0 - if palette_range is not None: - start, end = palette_range - plotly_arguments["color_continuous_scale"] = px.colors.sample_colorscale( - palette_continuous, number_of_colors, low=start, high=end - ) - - elif method == "summary": - logging.info("sequence-plotter - summary plotly") - - abs_facet_row_spacing = kwargs.pop("abs_facet_row_spacing", 20) - abs_facet_col_spacing = kwargs.pop("abs_facet_col_spacing", 20) - facet_row_spacing = kwargs.pop( - "facet_row_spacing", abs_facet_row_spacing / height if height else 0.1 - ) - facet_col_spacing = kwargs.pop( - "facet_col_spacing", abs_facet_col_spacing / (width or 1000) - ) - - plotly_arguments["facet_row_spacing"] = facet_row_spacing - plotly_arguments["facet_col_spacing"] = facet_col_spacing - - logging.debug(f"{plotly_arguments=}") - logging.debug(f"{kwargs=}") - - fig = None - if method in ["fig_pr_cycle", "fig_pr_cell"]: - fig = px.line( - curves, - **plotly_arguments, - **kwargs, - ) - - if method == "fig_pr_cycle" and group_cells: - try: - fig.for_each_trace( - functools.partial( - legend_replacer, - df=curves, - group_legends=group_legend_muting, - ) - ) - if markers is not True: - fig.for_each_trace(remove_markers) - except Exception as e: - print(f"sequence_plotter - fig_pr_cycle - failed {e} [{z}]") - - elif method == "film": - fig = px.density_heatmap(curves, **plotly_arguments, **kwargs) - if histscale is None: - color_bar_txt = f"{y_label} ({y_unit})" - else: - color_bar_txt = f"{y_label} ({histscale})" - - if histscale == "hist-eq": - fig = fig.for_each_trace(lambda _x: _hist_eq(_x)) - - fig.update_layout(coloraxis_colorbar_title_text=color_bar_txt) - - elif method == "summary": - if spread: - logging.info( - "using spread is an experimental feature and might not work as expected" - ) - fig = spread_plot( - curves, - plotly_arguments=plotly_arguments, - y_label_mapper=y_label_mapper, - **kwargs, - ) - else: - # remove all kwargs that are only intended for spread_plot - _ = kwargs.pop("height_fractions_spread", None) - _ = plotly_arguments.pop("plotly_start_cell", None) - _ = plotly_arguments.pop("plotly_shared_xaxes", None) - _ = plotly_arguments.pop("plotly_vertical_spacing", None) - _ = kwargs.pop("plotly_start_cell", None) - _ = kwargs.pop("plotly_shared_xaxes", None) - _ = kwargs.pop("plotly_vertical_spacing", None) - - fig = px.line( - curves, - **plotly_arguments, - **kwargs, - ) - - if group_cells: # all cells in same group has same color - try: - fig.for_each_trace( - functools.partial( - legend_replacer, - df=curves, - group_legends=group_legend_muting, - ) - ) - if markers is not True: - fig.for_each_trace(remove_markers) - except Exception as e: - print(f"sequence_plotter - summary - failed {e} [{group}]") - - if y_label_mapper and not spread: - y_label_mapper = _plotly_y_label_cleaner(y_label_mapper) - annotations = fig.layout.annotations - if annotations: - try: - for i in range(len(annotations)): - row = i + 1 - if annotations[i].text.startswith("variable="): - variable = annotations[i].text.split("=")[1] - if variable in y_label_mapper: - v = y_label_mapper[variable] - fig.for_each_yaxis( - functools.partial(y_axis_replacer, label=v), - row=row, - ) - else: - for k, v in y_label_mapper.items(): - if annotations[i].text.endswith(k): - fig.for_each_yaxis( - functools.partial(y_axis_replacer, label=v), - row=row, - ) - break - - fig.update_annotations(text="") - - except Exception as e: - print( - f"sequence_plotter - summary - y-label mapper failed {e} [{group}]" - ) - else: - try: - fig.for_each_yaxis( - functools.partial(y_axis_replacer, label=y_label_mapper), - ) - except Exception as e: - print( - f"sequence_plotter - summary - y-label mapper - no annotations - failed {e} [{group}]" - ) - print(f"y_label_mapper: {y_label_mapper}") - print(f"annotations: {annotations}") - - else: - print(f"method '{method}' is not supported by plotly") - - return fig - - if backend == "seaborn": - number_of_data_points = len(curves) - if number_of_data_points > MAX_POINTS_SEABORN_FACET_GRID: - print( - f"WARNING! Too many data points for seaborn to plot: " - f"{number_of_data_points} > {MAX_POINTS_SEABORN_FACET_GRID}" - ) - print( - f" - Try to reduce the number of data points " - f"e.g. by selecting fewer cycles and interpolating " - f"using the `number_of_points` and `max_cycle` or `cycles_to_plot` arguments." - ) - return - - if method == "fig_pr_cell": - seaborn_arguments["height"] = kwargs.pop("height", 3) - seaborn_arguments["aspect"] = kwargs.pop("height", 1) - sns.set_theme(style="darkgrid") - x = seaborn_arguments.get("x", _CCOLS.capacity) - y = seaborn_arguments.get("y", _CCOLS.potential) - row = seaborn_arguments.get("row", hdr_journal.group) - hue = seaborn_arguments.get("hue", _CCOLS.cycle_num) - col = seaborn_arguments.get("col", hdr_journal.sub_group) - height = seaborn_arguments.get("height", 3) - aspect = seaborn_arguments.get("aspect", 1) - - if palette_discrete is not None: - seaborn_arguments["palette"] = getattr( - sns.color_palette, palette_discrete - ) - - number_of_columns = len(curves[col].unique()) - if number_of_columns > 6: - print( - f"WARNING! {number_of_columns} columns is a lot for seaborn to plot" - ) - print( - f" - consider making the plot manually (use the `.data` attribute to get the data)" - ) - - legend_items = curves[hue].unique() - number_of_legends = len(legend_items) - palette = ( - seaborn_arguments.get("palette", "viridis") - if number_of_legends > 10 - else None - ) - - g = sns.FacetGrid( - curves, - hue=hue, - row=row, - col=col, - height=height, - aspect=aspect, - palette=palette, - ) - - g.map(plt.plot, x, y) - - if number_of_legends > 10: - vmin = legend_items.min() - vmax = legend_items.max() - - sm = plt.cm.ScalarMappable( - cmap=palette, norm=plt.Normalize(vmin=vmin, vmax=vmax) - ) - cbar = g.figure.colorbar( - sm, - ax=g.figure.axes, - location="right", - extend="max", - # pad=0.05/number_of_columns, - ) - cbar.ax.set_title("Cycle") - else: - g.add_legend() - - fig = g.fig - g.set_xlabels(labels[x]) - g.set_ylabels(labels[y]) - return fig - - if method == "fig_pr_cycle": - sns.set_theme(style="darkgrid") - seaborn_arguments["height"] = 4 - seaborn_arguments["aspect"] = 3 - seaborn_arguments["linewidth"] = 2.0 - g = sns.FacetGrid( - curves, - hue=z, - height=seaborn_arguments["height"], - aspect=seaborn_arguments["aspect"], - ) - g.map(plt.plot, x, y) - fig = g.fig - g.set_xlabels(x_label) - g.set_ylabels(y_label) - g.add_legend() - return fig - - if method == "film": - sns.set_theme(style="darkgrid") - seaborn_arguments["height"] = 4 - seaborn_arguments["aspect"] = 3 - seaborn_arguments["linewidth"] = 2.0 - g = sns.FacetGrid( - curves, - hue=z, - height=seaborn_arguments["height"], - aspect=seaborn_arguments["aspect"], - ) - g.map( - sns.kdeplot, - y, - x, - fill=True, - thresh=0, - levels=100, - cmap=palette_continuous, - ) - fig = g.fig - g.set_xlabels(x_label) - g.set_ylabels(y_label) - g.add_legend() - return fig - - if method == "summary": - sns.set_theme(style="darkgrid") - seaborn_arguments["height"] = 4 - seaborn_arguments["aspect"] = 3 - seaborn_arguments["linewidth"] = 2.0 - - x = seaborn_arguments.get("x", "cycle") - y = seaborn_arguments.get("y", "mean") - hue = seaborn_arguments.get("hue", None) - - labels = seaborn_arguments.get("labels", None) - x_label = labels.get(x, x) - - std = seaborn_arguments.get("error_y", None) - marker = "o" if seaborn_arguments.get("markers", False) else None - row = seaborn_arguments.get("row", None) - - g = sns.FacetGrid( - curves, - hue=hue, - height=seaborn_arguments["height"], - aspect=seaborn_arguments["aspect"], - row=row, - ) - - if std: - g.map(plt.errorbar, x, y, std, marker=marker, elinewidth=0.5, capsize=2) - else: - g.map(plt.plot, x, y, marker=marker) - - fig = g.figure - - g.set_xlabels(x_label) - if y_label_mapper: - for i, ax in enumerate(g.axes.flat): - ax.set_ylabel(y_label_mapper[i]) - g.add_legend() - return fig - - elif backend == "matplotlib": - print(f"{backend} not implemented yet") - - elif backend == "bokeh": - print(f"{backend} not implemented yet") - - else: - print(f"{backend} not implemented yet") - - -def _cycles_plotter( - collected_curves, - cycles=None, - x=_CCOLS.capacity, - y=_CCOLS.potential, - z=_CCOLS.cycle_num, - g="cell", - standard_deviation=None, - default_title="Charge-Discharge Curves", - backend="plotly", - method="fig_pr_cell", - match_axes=True, - **kwargs, -): - """Plot charge-discharge curves. - - Args: - collected_curves(pd.DataFrame): collected data in long format. - backend (str): what backend to use. - match_axes (bool): if True, all subplots will have the same axes. - method (str): 'fig_pr_cell' or 'fig_pr_cycle'. - - **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. - - Returns: - styled figure object - """ - # --- pre-processing --- - logging.debug("picking kwargs for current level - rest goes to sequence_plotter") - title = kwargs.pop("fig_title", default_title) - width = kwargs.pop("width", 900) - height = kwargs.pop("height", None) - palette = kwargs.pop("palette", None) - legend_position = kwargs.pop("legend_position", None) - legend_title = kwargs.pop("legend_title", None) - show_legend = kwargs.pop("show_legend", None) - cols = kwargs.pop("cols", 3) - sub_fig_min_height = kwargs.pop("sub_fig_min_height", 200) - figure_border_height = kwargs.pop("figure_border_height", 100) - # kwargs from default `BatchCollector.render` method not used by `sequence_plotter`: - journal = kwargs.pop("journal", None) - units = kwargs.pop("units", None) - - if palette is not None: - kwargs["palette_continuous"] = palette - kwargs["palette_discrete"] = palette - - if legend_title is None: - if method == "fig_pr_cell": - legend_title = "Cycle" - else: - legend_title = "Cell" - - no_cols = cols - - if method in ["fig_pr_cell", "film"]: - number_of_figs = len(collected_curves["cell"].unique()) - - elif method == "fig_pr_cycle": - if cycles is not None: - number_of_figs = len(cycles) - else: - number_of_figs = len(collected_curves[_CCOLS.cycle_num].unique()) - elif method == "summary": - number_of_figs = len(collected_curves["variable"].unique()) - sub_fig_min_height = 300 - else: - number_of_figs = 1 - - no_rows = math.ceil(number_of_figs / no_cols) - - if not height: - height = figure_border_height + no_rows * sub_fig_min_height - - fig = sequence_plotter( - collected_curves, - x=x, - y=y, - z=z, - g=g, - standard_deviation=standard_deviation, - backend=backend, - method=method, - cols=cols, - cycles=cycles, - width=width, - height=height, - **kwargs, - ) - if fig is None: - print("Could not create figure!") - return - - # Rendering: - if backend == "plotly": - template = f"{PLOTLY_BASE_TEMPLATE}+{method}" - - legend_orientation = "v" - if legend_position == "bottom": - legend_orientation = "h" - - legend_dict = { - "title": legend_title, - "orientation": legend_orientation, - } - title_dict = { - "text": title, - } - - fig.update_layout( - template=template, - title=title_dict, - legend=legend_dict, - showlegend=show_legend, - height=height, - width=width, - ) - if not match_axes: - fig.update_yaxes(matches=None) - fig.update_xaxes(matches=None) - - return fig - - -def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **kwargs): - """Plot summaries (value vs cycle number). - - Assuming data as pandas.DataFrame with either - 1) long format (where variables, for example charge capacity, are in the column "variable") or - 2) mixed long and wide format where the variables are own columns. - """ - - # start_cell is used to determine the starting cell for the subplots (plotly) - start_cell = kwargs.pop("start_cell", "bottom-left") - - col_headers = collected_curves.columns.to_list() - - # need to manually update this if new columns are added to collected_curves that should not be plotted: - not_available_for_plotting = [hdr_journal.label, hdr_journal.group_label, hdr_journal.selected] - - possible_id_vars = [ - "cell", - "cycle", - "equivalent_cycle", - "value", - "mean", - "std", - hdr_journal.group, - hdr_journal.sub_group, - ] - id_vars = [] - for n in possible_id_vars: - if n in col_headers: - col_headers.remove(n) - id_vars.append(n) - for n in not_available_for_plotting: - if n in col_headers: - col_headers.remove(n) - - if "variable" not in col_headers: - collected_curves = collected_curves.melt( - id_vars=id_vars, value_vars=col_headers - ) - - normalize_cycles = True if "equivalent_cycle" in id_vars else False - group_it = False if hdr_journal.group in id_vars else True - - cols = kwargs.pop("cols", 1) - - z = "cell" - g = "variable" - - if normalize_cycles: - x = "equivalent_cycle" - x_label = "Equivalent Cycle" - x_unit = "cum/nom.cap." - else: - x = "cycle" - x_label = "Cycle" - x_unit = "n." - - if group_it: - group_cells = False - y = "mean" - standard_deviation = "std" - - else: - y = "value" - standard_deviation = None - group_cells = kwargs.pop("group_cells", True) - - units = kwargs.pop("units", None) - label_mapper = { - f"{y}": None, - } - # order the variables by a given order: - order_variables = kwargs.pop("order_variables", None) - if order_variables: - collected_curves[g] = collected_curves[g].astype( - pd.CategoricalDtype(categories=order_variables, ordered=True) - ) - collected_curves = collected_curves.sort_values(by=[g, z, x]) - - if units: - label_mapper[y] = {} - variables = list(collected_curves[g].unique()) - for v in variables: - # unit label - u_sub = None - if v.endswith("_areal") or v.endswith("_areal_cv"): - u_sub = units["cellpy_units"].specific_areal - elif v.endswith("_gravimetric") or v.endswith("_gravimetric_cv"): - u_sub = units["cellpy_units"].specific_gravimetric - elif v.endswith("_volumetric") or v.endswith("_volumetric_cv"): - u_sub = units["cellpy_units"].specific_volumetric - - u_top = None - if "_capacity" in v: - u_top = units["cellpy_units"].charge - if "_norm" in v: - u_top = "normalized" - if v == "coulombic_efficiency": - u_top = "%" - - u = u_top or "Value" - - # variable label - v2 = v.split("_") - if u_sub: - u_sub = u_sub.replace("**", "") - u = f"{u}/{u_sub}" - if v2[-1] == "cv": - v2 = v2[:-2] - v2.append("cv") - else: - v2 = v2[:-1] - v2 = " ".join(v2).title() - - if v2.endswith("Cv"): - v2 = v2.replace("Cv", "CV") - - label_mapper[y][v] = f"{v2} ({u})" - - # TODO: need to refactor and fix how the classes are created so that leftover kwargs are not sent to the backend - # (for example if another collector is used and registers a kwarg without popping it) - - _ = kwargs.pop("method", None) # also set in BatchCyclesCollector - height_fractions = kwargs.pop("height_fractions", []) - - fig = _cycles_plotter( - collected_curves, - x=x, - y=y, - z=z, - g=g, - standard_deviation=standard_deviation, - x_label=x_label, - x_unit=x_unit, - y_label_mapper=label_mapper[y], - group_cells=group_cells, - default_title="Summary Plot", - backend=backend, - method="summary", - cycles=cycles_to_plot, - cols=cols, - **kwargs, - ) - - if backend == "plotly": - # TODO: implement having different heights of the subplots - - if len(height_fractions) > 0: - # Determine number of rows in the original figure - print("THIS IS EXPERIMENTAL") - number_of_rows = len([key for key in fig.layout if key.startswith("yaxis")]) - if number_of_rows == 0: - number_of_rows = 1 # Default to 1 if no y-axes found - - # Only proceed if height_fractions matches the number of rows - if len(height_fractions) != number_of_rows: - print( - f"Warning: height_fractions length ({len(height_fractions)}) does not match number of rows ({number_of_rows}). Ignoring height_fractions." - ) - else: - # Update subplot heights using make_subplots parameters - from plotly.subplots import make_subplots - - # Get current figure data and layout properties - current_data = fig.data - current_layout = fig.layout - - # Create new figure with custom row heights - new_fig = make_subplots( - rows=number_of_rows, - cols=1, - start_cell=start_cell, - shared_xaxes=True, - row_heights=height_fractions[::-1], - vertical_spacing=0.02, - subplot_titles=[ann.text for ann in current_layout.annotations] - if current_layout.annotations - else None, - ) - - new_height_fractions = {} - for key in new_fig.layout: - if key.startswith("yaxis"): - new_height_fractions[key] = new_fig.layout[key].domain - - # Add traces from original figure - for trace in current_data: - new_fig.add_trace(trace) - - # Update layout properties from original figure (including theme) - new_fig.update_layout(current_layout) - for key in new_height_fractions: - new_fig.layout[key].domain = new_height_fractions[key] - - fig = new_fig - # Preserve x-axis linking and only show labels on bottom row with small gaps - fig.update_xaxes(matches="x") - fig.update_yaxes(matches=None, showticklabels=True) - - # Only show x-axis labels on the bottom subplot (not needed anymore?) - # for i in range(1, len(height_fractions)): - # fig.update_xaxes(showticklabels=False, row=i, col=1) - - return fig - if backend == "seaborn": - print("using seaborn (experimental feature)") - return fig - if backend == "matplotlib": - print("using matplotlib (experimental feature)") - return fig - if backend == "bokeh": - print("using bokeh (experimental feature)") - return fig - - -def cycles_plotter( - collected_curves, - cycles_to_plot=None, - backend="plotly", - method="fig_pr_cell", - x_unit="mAh/g", - y_unit="V", - **kwargs, -): - """Plot charge-discharge curves. - - Args: - collected_curves(pd.DataFrame): collected data in long format. - cycles_to_plot (list): cycles to plot - backend (str): what backend to use. - method (str): 'fig_pr_cell' or 'fig_pr_cycle'. - x_unit (str): unit for x-axis. - y_unit (str): unit for y-axis. - - **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. - - Returns: - styled figure object - """ - - if cycles_to_plot is not None: - unique_cycles = list(collected_curves.cycle.unique()) - if len(unique_cycles) > 50: - print(f"Too many cycles - setting it to default {DEFAULT_CYCLES}") - cycles_to_plot = DEFAULT_CYCLES - - return _cycles_plotter( - collected_curves, - x=_CCOLS.capacity, - y=_CCOLS.potential, - z=_CCOLS.cycle_num, - g="cell", - x_unit=x_unit, - y_unit=y_unit, - default_title="Charge-Discharge Curves", - backend=backend, - method=method, - cycles=cycles_to_plot, - **kwargs, - ) - - -def ica_plotter( - collected_curves, - cycles_to_plot=None, - backend="plotly", - method="fig_pr_cell", - direction="charge", - **kwargs, -): - """Plot charge-discharge curves. - - Args: - collected_curves(pd.DataFrame): collected data in long format. - cycles_to_plot (list): cycles to plot - backend (str): what backend to use. - method (str): 'fig_pr_cell' or 'fig_pr_cycle' or 'film'. - direction (str): 'charge' or 'discharge'. - - **kwargs: consumed first in current function, rest sent to backend in sequence_plotter. - - Returns: - styled figure object - """ - - if cycles_to_plot is None: - unique_cycles = list(collected_curves.cycle.unique()) - max_cycle = max(unique_cycles) - if len(unique_cycles) > 50: - cycles_to_plot = DEFAULT_CYCLES - max_cycle = max(cycles_to_plot) - else: - max_cycle = max(cycles_to_plot) - - if direction not in ["charge", "discharge"]: - print(f"direction='{direction}' not allowed - setting it to 'charge'") - direction = "charge" - if method == "film": - kwargs["range_y"] = kwargs.pop("range_y", None) or (1, max_cycle) - - return _cycles_plotter( - collected_curves, - x="voltage", - y="dqdv", - z="cycle", - g="cell", - x_label="Voltage", - x_unit="V", - y_label="dQ/dV", - y_unit="mAh/g/V.", - default_title=f"Incremental Analysis Plots", - direction=direction, - backend=backend, - method=method, - cycles=cycles_to_plot, - **kwargs, - ) - - -def histogram_equalization(image: np.array) -> np.array: - """Perform histogram equalization on a numpy array.""" - # from http://www.janeriksolem.net/histogram-equalization-with-python-and.html - number_bins = 256 - scale = 100 - image[np.isnan(image)] = 0.0 - image_histogram, bins = np.histogram(image.flatten(), number_bins, density=True) - cdf = image_histogram.cumsum() # cumulative distribution function - cdf = (scale - 1) * cdf / cdf[-1] # normalize - # use linear interpolation of cdf to find new pixel values - image_equalized = np.interp(image.flatten(), bins[:-1], cdf) - - return image_equalized.reshape(image.shape) +# Drawing lives in cellpy.plotting.collected (#657). Collection helpers below +# stay here. `_select_direction` is re-exported from plotting.collected for +# tests / callers that still import it from this module. def _check(): diff --git a/tests/data/collector_figure_specs.json b/tests/data/collector_figure_specs.json new file mode 100644 index 00000000..0950f8c7 --- /dev/null +++ b/tests/data/collector_figure_specs.json @@ -0,0 +1,766 @@ +{ + "figures": { + "collected_cycles_per_cell[plotly]": { + "axis_titles": { + "xaxis": "Capacity (mAh/g)", + "xaxis2": "Capacity (mAh/g)", + "yaxis": "Voltage (V)", + "yaxis2": null + }, + "backend": "plotly", + "n_axes": 4, + "n_traces": 35, + "traces": [ + { + "mode": "lines", + "name": "1", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "1", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "2", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "2", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "3", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "3", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "4", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "4", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "5", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "5", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "6", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "6", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "7", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "7", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "8", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "8", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "9", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "9", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "10", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "10", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "11", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "11", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "12", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "12", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "13", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "13", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "14", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "14", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "15", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "15", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "16", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "16", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "17", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "17", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + }, + { + "mode": "lines", + "name": "18", + "type": "scattergl", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + } + ] + }, + "collected_ica_film[plotly]": { + "axis_titles": { + "xaxis": "Voltage (V)", + "xaxis2": "Voltage (V)", + "yaxis": "Cycle (n.)", + "yaxis2": null + }, + "backend": "plotly", + "n_axes": 4, + "n_traces": 2, + "traces": [ + { + "mode": null, + "name": "", + "type": "histogram2d", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": null, + "name": "", + "type": "histogram2d", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x2", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y2" + } + ] + }, + "collected_summary[plotly]": { + "axis_titles": { + "xaxis": "Cycle (n.)", + "yaxis": "value" + }, + "backend": "plotly", + "n_axes": 2, + "n_traces": 2, + "traces": [ + { + "mode": "lines", + "name": "20160805_test001_46_cc", + "type": "scatter", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "20160805_test001_47_cc", + "type": "scatter", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + } + ] + }, + "collected_summary_spread[plotly]": { + "axis_titles": { + "xaxis": "Cycle (n.)", + "yaxis": "charge_capacity_gravimetric" + }, + "backend": "plotly", + "n_axes": 2, + "n_traces": 3, + "traces": [ + { + "mode": "lines", + "name": "test001", + "type": "scatter", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "Upper Bound test001", + "type": "scatter", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + }, + { + "mode": "lines", + "name": "Lower Bound test001", + "type": "scatter", + "x": { + "first": null, + "last": null, + "n": 2 + }, + "xaxis": "x", + "y": { + "first": null, + "last": null, + "n": 2 + }, + "yaxis": "y" + } + ] + } + } +} diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 4ed09e6d..1f39aaf9 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -11,10 +11,18 @@ regression fails loudly. They require the plotting extras; the `full` CI job installs them (`uv sync --extra batch`), and they skip cleanly when the extras or the batch testdata are absent. + +Collector figure structure is snapshotted in +``tests/data/collector_figure_specs.json`` (#657) — regenerate with:: + + MPLBACKEND=Agg uv run python -c "from tests.test_collectors import write_collector_figure_specs; write_collector_figure_specs()" """ from __future__ import annotations +import json +from pathlib import Path + import pytest # Reuse the batch fixtures (clean_dir → batch_instance → populated_batch). @@ -23,15 +31,21 @@ clean_dir, populated_batch, ) +from tests.figure_spec_support import describe_figure plotly = pytest.importorskip("plotly", reason="plotting extras (batch) not installed") +from cellpy.utils import collectors as collectors_mod # noqa: E402 from cellpy.utils.collectors import ( # noqa: E402 BatchCyclesCollector, BatchICACollector, BatchSummaryCollector, ) +COLLECTOR_SNAPSHOT_PATH = ( + Path(__file__).resolve().parent / "data" / "collector_figure_specs.json" +) + def _assert_ran(collector): """A collector that autoran must have collected data and built a figure.""" @@ -103,3 +117,99 @@ def test_select_direction_handles_both_encodings(): without = pd.DataFrame({"v": [1, 2]}) assert len(_select_direction(without, "charge")) == 2 + + +def test_drawing_bodies_live_in_plotting_not_collectors(): + """Collectors no longer define sequence/summary/cycles/ica/spread plotters (#657).""" + for name in ( + "sequence_plotter", + "summary_plotter", + "cycles_plotter", + "ica_plotter", + "spread_plot", + "_cycles_plotter", + ): + assert not hasattr(collectors_mod, name), name + + +def test_batch_collector_plot_aliases_render(populated_batch): + """``BatchCollector.plot`` is a thin alias of ``render`` (#657).""" + collector = BatchSummaryCollector(populated_batch, autorun=False) + collector.collect() + collector.plot() + assert collector.figure is not None + + +def _collector_figure_menu(populated_batch) -> dict: + """Minimum collector column for the #657 oracle.""" + from cellpy.plotting import collected_plot + + summary = BatchSummaryCollector(populated_batch, autorun=False) + summary.collect() + cycles = BatchCyclesCollector(populated_batch, autorun=False) + cycles.collect() + ica_film = BatchICACollector(populated_batch, plot_type="film", autorun=False) + ica_film.collect() + # spread_plot needs group_it so the frame carries mean/std columns + summary_spread = BatchSummaryCollector( + populated_batch, group_it=True, spread=True, autorun=False + ) + summary_spread.collect() + + figures = { + "collected_summary[plotly]": describe_figure( + collected_plot(summary.data, family_kind="summary", backend="plotly") + ), + "collected_cycles_per_cell[plotly]": describe_figure( + collected_plot( + cycles.data, + family_kind="cycles", + layout="per_cell", + backend="plotly", + ) + ), + "collected_ica_film[plotly]": describe_figure( + collected_plot( + ica_film.data, + family_kind="ica", + kind="film", + backend="plotly", + ) + ), + "collected_summary_spread[plotly]": describe_figure( + collected_plot( + summary_spread.data, + family_kind="summary", + kind="spread", + backend="plotly", + ) + ), + } + return {"figures": figures} + + +def write_collector_figure_specs(populated_batch=None) -> Path: + """Regenerate ``collector_figure_specs.json`` (dev helper / snapshot regen).""" + if populated_batch is None: + raise RuntimeError("pass a populated_batch when calling from tests") + specs = _collector_figure_menu(populated_batch) + COLLECTOR_SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True) + COLLECTOR_SNAPSHOT_PATH.write_text( + json.dumps(specs, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return COLLECTOR_SNAPSHOT_PATH + + +@pytest.mark.essential +def test_collector_figure_structure_matches_snapshot(populated_batch): + """Collector layouts are part of the plotting contract (#657).""" + if not COLLECTOR_SNAPSHOT_PATH.is_file(): + pytest.skip(f"missing snapshot {COLLECTOR_SNAPSHOT_PATH}") + expected = json.loads(COLLECTOR_SNAPSHOT_PATH.read_text(encoding="utf-8"))["figures"] + actual = _collector_figure_menu(populated_batch)["figures"] + assert set(actual) == set(expected) + for name, want in expected.items(): + got = actual[name] + assert got["backend"] == want["backend"], name + assert got.get("n_traces") == want.get("n_traces"), name + assert got.get("n_axes") == want.get("n_axes"), name