diff --git a/.issueflows/03-solved-issues/issue647_original.md b/.issueflows/03-solved-issues/issue647_original.md new file mode 100644 index 00000000..c2d79821 --- /dev/null +++ b/.issueflows/03-solved-issues/issue647_original.md @@ -0,0 +1,25 @@ +# Issue #647: Port raw_plot and cycle_info_plot + +Source: https://github.com/jepegit/cellpy/issues/647 + +## Original issue text + +## Context + +Part of epic #567 (Stage 2 — Other plot families on the same skeleton). Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md`. + +## Scope + +Add `prepare/raw.py` and `prepare/steps.py`; route both public functions through the shared backends. Keep `cycle_info_plot`'s matplotlib single-cycle limitation unless expanding it is trivial and oracle-covered. Axis/legend text goes through `units_label()` via `plotting.labels` (extend `labels.py` beyond legend/marker helpers as needed). + +## Acceptance + +- Oracle cases for both functions × both backends green. +- No hand-composed unit f-strings remain in these two code paths. +- Header lookups use the public schema/header helpers (no new hard-coded legacy names). + +## Depends on + +#646 + +Part of epic #567. diff --git a/.issueflows/03-solved-issues/issue647_plan.md b/.issueflows/03-solved-issues/issue647_plan.md new file mode 100644 index 00000000..abc1ea84 --- /dev/null +++ b/.issueflows/03-solved-issues/issue647_plan.md @@ -0,0 +1,115 @@ +# Issue #647 — Plan: port `raw_plot` and `cycle_info_plot` to prepare→spec→render + +## Goal + +Route public `raw_plot` and `cycle_info_plot` through **context → registry → prepare → backend.render**: add `prepare/raw.py` and `prepare/steps.py`, register two families, move private plotly/matplotlib layout into shared backends (`kind` branches), and replace hand-composed unit f-strings with `units_label()` helpers via `plotting.labels`. Oracle green for both functions × both backends. Keep `cycle_info_plot` matplotlib single-cycle asymmetry. + +## Constraints + +- Plan of record: [`architecture-plan/cellpy2-plotting-redesign-plan.md`](../../../architecture-plan/cellpy2-plotting-redesign-plan.md) Phase 3; epic [`.issueflows/05-epics/epic567_plan.md`](../05-epics/epic567_plan.md) Stage 2 issue 2 (Published: #647). +- Depends on #646 (merged): `prepare/curves.py`, `spec.extras["kind"]` dispatch, `backend=` / `interactive=` warn_once pattern. +- Design notes: [`plotting-prepare.md`](../04-designs-and-guides/plotting-prepare.md), [`plotting-backends.md`](../04-designs-and-guides/plotting-backends.md), [`plotting-registry.md`](../04-designs-and-guides/plotting-registry.md). +- Epic already decided: **do not** expand matplotlib `cycle_info_plot` to multi-cycle unless trivial + oracle-covered (default: keep asymmetry). +- Oracle gate: `tests/test_figure_specs.py` cases `raw_plot[*]` and `cycle_info_plot[*]` green; prefer **no** intentional `figure_specs.json` regen. +- Scope: these two entry points only — not ICA/DVA (#648), not collectors. +- Acceptance: no new hard-coded legacy header names; no hand-composed unit f-strings in these two code paths. + +### Prior art + +- `raw_plot` — `cellpy/utils/plotutils.py` (~1445–1712): copy `cell.data.raw`, resolve columns via `_LiveHeaders(cell, "raw")`, build y-lists from `plot_type` / explicit `y`, optional time-unit x column via pint `Q`, then **inline** plotly (`px.line` / `make_subplots`) or matplotlib (single / twin / nrows). Labels today: `f"Voltage ({cell.data.raw_units.voltage})"` etc. Public switch still `interactive=` (bool), not `backend=`. +- `cycle_info_plot` + `_cycle_info_plot_plotly` / `_cycle_info_plot_matplotlib` / `_get_info` / `_plot_step` — same module (~1715–2055). Merge scaled raw + step-table deltas; plotly multi-cycle hover; matplotlib single-cycle twin + step spans. Headers already `_LiveHeaders` (Phase 0); matplotlib still warns on multi-cycle. +- `#646` mirror — `prepare/curves.py` + `registry` family `"cycles"` + `extras["kind"]=="cycles"` in `PlotlyBackend` / `MatplotlibBackend` + thin public orchestrator in `plotutils`. **Mirror this shape twice** (raw + cycle_info). +- `_LiveHeaders` — `plotutils.py` (~551+): schema-backed per-cell header resolution. Required for both prepares; do not reintroduce module-level `get_headers_*()` singletons. +- `units_label` — `cellpy/units.py`; already used in summary/curves prepare. Issue asks extension of `cellpy/plotting/labels.py` beyond legend/marker helpers. +- `CellContext` — `cellpy/plotting/context.py`: summary-oriented today; prepares can reach `ctx.cell` (same as curves) rather than expanding context in this issue. +- Oracle: `tests/figure_spec_support.py` `_other_family_cases()` — both functions × both backends (still `interactive=`). +- Smoke: `tests/test_plotutils_headers.py` (`test_raw_plot_*`, `test_cycle_info_plot_*`). +- Toolbox: scanners unrelated. Graphify present but no need to query beyond known plotting communities — prior art from #646 + plotutils is sufficient. + +## Approach + +1. **Register two families** in `cellpy/plotting/registry.py` + - `"raw"` — `extras={"entry_point": "raw_plot", "kind": "raw"}`. + - `"cycle_info"` — `extras={"entry_point": "cycle_info_plot", "kind": "cycle_info"}`. + - `column_builder` can list the columns the prepare actually uses (raw: voltage/current/… stems; cycle_info: time/voltage/current + step stats). Keep them out of the summary oracle menu (same pattern as `"cycles"` / `families(entry_point=...)`). + +2. **`cellpy/plotting/prepare/raw.py`** + - `RawPrepareConfig` mirrors public `raw_plot` args (`y`, `y_label`, `x`, `x_label`, `title`, `plot_type`, `double_y`, size knobs, backend, `additional_kwargs`). + - `prepare(ctx, family, config) -> (frame, FigureSpec)`: + - Resolve headers via `_LiveHeaders` (import from plotutils **or** move helper into `cellpy.plotting.headers` if the import cycle is ugly — prefer move only if needed). + - Build the plotting frame (including synthetic `test_time_hrs` / days / years columns). + - Emit `FigureSpec` with panels/axes describing the subplot layout (1 row, twin-y, or N rows); put render knobs in `extras` (`kind="raw"`, `y` column list, labels, `double_y`, height heuristics, `backend`, kwargs). + - Axis labels: go through new helpers in `plotting.labels` (see step 4) — no `f"... ({raw_units...})"` in prepare or backends. + +3. **`cellpy/plotting/prepare/steps.py`** (cycle-info family) + - `CycleInfoPrepareConfig`: `cycle`, `t_unit` / `v_unit` / `i_unit`, `get_axes`, title/size knobs, backend, kwargs. + - Prepare: compute scalers (`unit_scaler_from_raw`), filter/merge raw+steps (same columns as today), attach scaled series on the returned frame; stash step-annotation inputs / hover customdata columns in `extras` as needed so backends do not re-query the cell. + - Matplotlib single-cycle clamp stays in prepare **or** the mpl branch (warn + take first) — behaviour unchanged. + - `FigureSpec.extras["kind"] = "cycle_info"`. + +4. **`plotting.labels` axis helpers** + - Add small formatters, e.g. `quantity_label(name, unit)` → `"Voltage (V)"`, and/or thin wrappers that call `units_label(physical_property, ..., units=...)` then compose the display name. + - **Raw path:** prefer labelling from the cell’s **raw** unit spec when that is what the series still carries (today’s behaviour); if `units_label` only accepts `CellpyUnits`, pass `cell.raw_units` when compatible, else format via the helper using the same unit strings the frame was scaled with (cycle_info already has explicit `t_unit`/`v_unit`/`i_unit`). + - Goal: zero hand-rolled `f"{name} ({unit})"` left in the two public paths / their private helpers. + +5. **Backend branches** + - In `PlotlyBackend.render` / `MatplotlibBackend.render`: + - `kind == "raw"` → port current inline raw layout. + - `kind == "cycle_info"` → port `_cycle_info_plot_*` (plus keep `_get_info` / `_plot_step` as private backend or prepare helpers). + - else existing summary / cycles paths. + - Mechanical port first (oracle parity). Delete the live private forks from `plotutils` once unused. + +6. **Thin public entry points** in `cellpy/utils/plotutils.py` + - Same orchestration as `cycles_plot`: resolve `backend=` vs deprecated `interactive=` (`warn_once`, removal 2.1) → `from_source` → `registry.get(...)` → prepare → `get_backend(...).render`. + - Preserve `raw_plot` return figure; preserve `cycle_info_plot` `get_axes` / `fig.show()` / matplotlib axes semantics (including plotly default show + `None` return that the oracle already documents). + - Register deprecations in `_deprecation` seed + regen `DEPRECATIONS.md`. + +7. **Tests / oracle harness** + - Point `raw_plot[*]` / `cycle_info_plot[*]` cases at `backend=` (drop `interactive=` noise). + - New focused tests (names flexible): prepare returns `(frame, FigureSpec)` with correct `kind`; `interactive=` warns+maps; no private `_cycle_info_plot_*` left on the public path; header smoke tests still pass. + - Prefer no snapshot regen; if axis-title text changes solely because of `units_label` wording, regenerate in the same commit and note it in the PR. + +8. **Design notes** + - Update `plotting-prepare.md` (raw + steps) and `plotting-backends.md` (`kind` raw / cycle_info). Brief registry note for the two families. + +## Files to touch + +| Path | Change | +|---|---| +| `cellpy/plotting/prepare/raw.py` | **new** — raw frame + `FigureSpec` | +| `cellpy/plotting/prepare/steps.py` | **new** — cycle-info merge/scale + `FigureSpec` | +| `cellpy/plotting/prepare/__init__.py` | export / note | +| `cellpy/plotting/registry.py` | register `"raw"` and `"cycle_info"` | +| `cellpy/plotting/labels.py` | axis/quantity label helpers via `units_label` | +| `cellpy/plotting/backends/plotly.py` | `kind` branches for raw + cycle_info | +| `cellpy/plotting/backends/mpl.py` | same | +| `cellpy/utils/plotutils.py` | thin `raw_plot` / `cycle_info_plot`; delete private layout helpers | +| `cellpy/plotting/headers.py` (optional) | move `_LiveHeaders` only if import cycle forces it | +| `cellpy/_deprecation.py` + `DEPRECATIONS.md` | `interactive=` for both entry points | +| `tests/figure_spec_support.py` | cases → `backend=` | +| `tests/test_raw_prepare.py` / `tests/test_cycle_info_prepare.py` (flexible) | **new** — prepare / deprecations / no private fork | +| `tests/test_plotutils_headers.py` | keep green; adjust kwargs if needed | +| `tests/test_figure_specs.py` | run; regen snapshot only if titles intentionally change | +| `.issueflows/04-designs-and-guides/plotting-*.md` | document the two prepares + kinds | + +## Test strategy + +```bash +uv sync --extra batch +MPLBACKEND=Agg uv run pytest tests/test_raw_prepare.py tests/test_cycle_info_prepare.py tests/test_plotutils_headers.py tests/test_figure_specs.py -q +MPLBACKEND=Agg uv run pytest -m essential --ignore=tests/test_arbin_variants_two_stage.py +``` + +Parity focus: default oracle cell × both backends for both functions; `plot_type` smoke for raw; cycle=3 for cycle_info; matplotlib multi-cycle warn + single-cycle behaviour unchanged. + +## Open questions + +1. **`kind` / registry names** — **Recommend:** `"raw"` / `"cycle_info"` (matches entry points; issue’s `prepare/steps.py` is the module name, not the kind string). Alternative: `"steps"` as kind — slightly clearer file↔kind link, worse API symmetry. +2. **Flip `backend=` / deprecate `interactive=` on both?** — **Recommend:** yes (global epic policy; matches #639/#646; oracle harness update is cheap). +3. **Where `_LiveHeaders` lives** — **Recommend:** keep in `plotutils` and import into prepare unless that creates a cycle; then move to `cellpy/plotting/headers.py`. Do not duplicate. +4. **Raw axis units source** — **Recommend:** label with the unit string that matches the plotted series (today: `raw_units` via a labels helper). Do not silently switch to `cellpy_units` if that changes oracle axis titles. Alternative: always `cellpy_units` — only if we accept snapshot churn. +5. **Matplotlib multi-cycle for `cycle_info_plot`?** — **Recommend:** keep single-cycle asymmetry (epic default). Expanding is a follow-up. + +## Scope check + +One Stage-2 slice: two prepares + two registry families + two backend kinds + thin public APIs + label helpers + deprecations. Fits a single PR. Follow-up already published: #648 (ICA/DVA). diff --git a/.issueflows/03-solved-issues/issue647_status.md b/.issueflows/03-solved-issues/issue647_status.md new file mode 100644 index 00000000..52f20e4e --- /dev/null +++ b/.issueflows/03-solved-issues/issue647_status.md @@ -0,0 +1,18 @@ +# Issue #647 — Status + +- [x] Done + +## What's done + +- Plan accepted; implemented on `647-port-raw-and-cycle-info-plot`. +- Moved `LiveHeaders` to `cellpy/plotting/headers.py` (re-exported as `plotutils._LiveHeaders`). +- Extended `plotting.labels` with `quantity_label` / `units_quantity_label`. +- Added `prepare/raw.py` + `prepare/steps.py`; registered `"raw"` / `"cycle_info"`. +- Backend `kind` branches for raw + cycle_info; thin public APIs + `interactive=` deprecations. +- Tests (`test_raw_cycle_info_prepare.py`), oracle harness `backend=`, design notes updated. +- Focused suite + essential gate green (oracle unchanged — no snapshot regen). +- HISTORY.md Unreleased bullet; closed via `/iflow-close`. + +## Remaining work + +- None (PR merge + `/iflow-cleanup` after merge). diff --git a/.issueflows/04-designs-and-guides/plotting-backends.md b/.issueflows/04-designs-and-guides/plotting-backends.md index 4fade8a7..69f37c28 100644 --- a/.issueflows/04-designs-and-guides/plotting-backends.md +++ b/.issueflows/04-designs-and-guides/plotting-backends.md @@ -4,7 +4,8 @@ Epic #567 Stage 1 needs one layout engine and prepare→spec→render for `summary_plot`, with two public backends (`plotly` | `matplotlib`). Stage 2 -(#646) adds a second render branch for `cycles_plot`. +adds further render branches for `cycles_plot` (#646) and `raw_plot` / +`cycle_info_plot` (#647). ## Decision @@ -16,14 +17,17 @@ Epic #567 Stage 1 needs one layout engine and prepare→spec→render for - **Static path:** `MatplotlibBackend.render(frame, spec)` (#639). Seaborn is used only for palette/style/faceting helpers (`relplot`); it is **not** a public backend name. `SeabornPlotBuilder` is deleted. -- **Public switch:** `summary_plot(..., backend="plotly"|"matplotlib")` and - `cycles_plot(..., backend=...)`. `interactive=` is a `warn_once` alias - (removal 2.1) on both. -- **Family dispatch (#646):** when `spec.extras.get("kind") == "cycles"`, - both backends take the voltage–capacity render path (ported from the old - private `_cycles_plotter_*` helpers). Otherwise they keep the summary path. +- **Public switch:** `summary_plot(..., backend="plotly"|"matplotlib")`, + `cycles_plot(..., backend=...)`, `raw_plot(..., backend=...)`, + `cycle_info_plot(..., backend=...)`. `interactive=` is a `warn_once` alias + (removal 2.1) on all of them. +- **Family dispatch:** `spec.extras.get("kind")` selects the render branch: + - `"cycles"` — voltage–capacity (#646) + - `"raw"` — raw time-series (#647) + - `"cycle_info"` — raw + step annotations (#647; matplotlib single-cycle) + - otherwise — summary path ## Links -- Issues #646, #639, #638, #637, #636; epic #567 +- Issues #647, #646, #639, #638, #637, #636; epic #567 - Plan of record: `architecture-plan/cellpy2-plotting-redesign-plan.md` §3.1 diff --git a/.issueflows/04-designs-and-guides/plotting-prepare.md b/.issueflows/04-designs-and-guides/plotting-prepare.md index 5e98f56b..5c7f369a 100644 --- a/.issueflows/04-designs-and-guides/plotting-prepare.md +++ b/.issueflows/04-designs-and-guides/plotting-prepare.md @@ -1,11 +1,11 @@ -# Plotting prepare (summary + curves) +# Plotting prepare (summary + curves + raw + cycle_info) ## Context `SummaryPlotDataPreparer` lived in `cellpy/utils/plotutils.py` and fed both plotly and seaborn builders. Epic #567 Stage 1 needs prepare → `FigureSpec` → -backend.render as the only summary path. Stage 2 (#646) extends the same -contract to `cycles_plot`. +backend.render as the only summary path. Stage 2 extends the same contract to +`cycles_plot` (#646), then `raw_plot` / `cycle_info_plot` (#647). ## Decision @@ -22,12 +22,23 @@ contract to `cycles_plot`. form/rest frames and styling knobs. Curve load seam defaults to `c.get_cap` (oracle-stable); `cellpycore.curves` preferred path can land later behind `_load_curve_frame`. +- **Raw prepare** lives in `cellpy/plotting/prepare/raw.py` (#647). + `spec.extras["kind"] == "raw"`; predefined `plot_type` menus and synthetic + time columns are resolved here. Axis labels use `plotting.labels` + (`units_quantity_label` / `quantity_label`) against `raw_units`. +- **Cycle-info prepare** lives in `cellpy/plotting/prepare/steps.py` (#647). + `spec.extras["kind"] == "cycle_info"`. Plotly path emits a merged scaled + frame; matplotlib keeps the single-cycle asymmetry and stashes the step + table on `extras["steps"]`. +- **`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. -- Public `summary_plot` / `cycles_plot` stay in `plotutils` and orchestrate - context → registry → prepare → `get_backend(backend).render`. +- Public `summary_plot` / `cycles_plot` / `raw_plot` / `cycle_info_plot` stay + in `plotutils` and orchestrate context → registry → prepare → + `get_backend(backend).render`. ## Links -- Issues #646, #639, #638; epic #567 +- Issues #647, #646, #639, #638; epic #567 - Related: `plotting-registry.md`, `plotting-backends.md` diff --git a/.issueflows/04-designs-and-guides/plotting-registry.md b/.issueflows/04-designs-and-guides/plotting-registry.md index ae4de9fc..8d6976ea 100644 --- a/.issueflows/04-designs-and-guides/plotting-registry.md +++ b/.issueflows/04-designs-and-guides/plotting-registry.md @@ -19,9 +19,9 @@ moves selection into `cellpy.plotting.registry`. backend consumption is #639. - **Oracle menu** (`tests/figure_spec_support.SUMMARY_FAMILIES`) derives from `families(entry_point="summary_plot")` so the snapshot menu cannot drift - from the runtime menu. Non-summary families (e.g. `"cycles"` for - `cycles_plot`, #646) register with `extras={"entry_point": "cycles_plot"}` - and are excluded from the summary oracle. + from the runtime menu. Non-summary families register with + `extras={"entry_point": "..."}` and are excluded from the summary oracle: + `"cycles"` (`cycles_plot`, #646), `"raw"` / `"cycle_info"` (#647). ## Alternatives considered diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index aa8f2c78..b69e25dd 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -8,6 +8,7 @@ uv run python -m cellpy._deprecation | Name | Replacement | Introduced | Removal | | --- | --- | --- | --- | +| `cycle_info_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 | | `cycles_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 | | `cycles_plot(xlim=...)` | `cycles_plot(x_range=...)` | 2.0 | 2.1 | | `cycles_plot(ylim=...)` | `cycles_plot(y_range=...)` | 2.0 | 2.1 | @@ -21,5 +22,6 @@ uv run python -m cellpy._deprecation | `legacy header attribute access (headers_normal / _summary / _step_table)` | `c.schema.raw / c.schema.steps / c.schema.summary` | 2.0 | 2.1 | | `make_new_cell` | `CellpyCell.vacant` | 2.0 | 2.1 | | `plotutils.summary_plot_legacy` | `cellpy.utils.plotutils.summary_plot (same figures, same options)` | 2.0 | 2.1 | +| `raw_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 | | `summary_plot(interactive=...)` | `backend="plotly"|"matplotlib"` | 2.0 | 2.1 | | `the 'dq' column of the ica output frame` | `the 'dqdv' column of the same frame` | 2.0 | 2.1 | diff --git a/HISTORY.md b/HISTORY.md index 1631e803..8f07dabf 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,8 @@ ## [Unreleased] +* Port raw_plot and cycle_info_plot to prepare→spec→render (#647). + * Port cycles_plot to prepare→spec→render (#646). * Matplotlib backend; retire SeabornPlotBuilder; unify backend= (#639). diff --git a/cellpy/_deprecation.py b/cellpy/_deprecation.py index 7defa239..77f510b7 100644 --- a/cellpy/_deprecation.py +++ b/cellpy/_deprecation.py @@ -177,6 +177,17 @@ def _seed_known_deprecations() -> None: "cycles_plot(y_range=...)", removal="2.1", ) + # Stage 2 (#647): raw_plot / cycle_info_plot backend=. + _register( + "raw_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", + ) + _register( + "cycle_info_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", + ) if __name__ == "__main__": diff --git a/cellpy/plotting/__init__.py b/cellpy/plotting/__init__.py index 62a59ef5..df200a88 100644 --- a/cellpy/plotting/__init__.py +++ b/cellpy/plotting/__init__.py @@ -38,7 +38,12 @@ make_matplotlib_manager, save_matplotlib_figure, ) -from cellpy.plotting.labels import legend_replacer, remove_markers +from cellpy.plotting.labels import ( + legend_replacer, + quantity_label, + remove_markers, + units_quantity_label, +) from cellpy.plotting.prepare import prepare_summary from cellpy.plotting.registry import ( PlotFamily, @@ -70,6 +75,8 @@ "make_matplotlib_manager", "make_plotly_template", "prepare_summary", + "quantity_label", "remove_markers", "save_matplotlib_figure", + "units_quantity_label", ] diff --git a/cellpy/plotting/backends/mpl.py b/cellpy/plotting/backends/mpl.py index 21a825d1..4eab658b 100644 --- a/cellpy/plotting/backends/mpl.py +++ b/cellpy/plotting/backends/mpl.py @@ -63,8 +63,13 @@ def __init__(self) -> None: def render(self, frame: Any, spec: FigureSpec) -> Any: """Render a tidy frame according to *spec* (matplotlib Figure).""" extras = dict(spec.extras or {}) - if extras.get("kind") == "cycles": + kind = extras.get("kind") + if kind == "cycles": return self._render_cycles(frame, spec) + if kind == "raw": + return self._render_raw(frame, spec) + if kind == "cycle_info": + return self._render_cycle_info(frame, spec) config = extras.get("config") c = extras.get("cell") @@ -1057,4 +1062,184 @@ def _render_cycles(self, frame: Any, spec: FigureSpec) -> Any: ax.set_ylim(y_range) return fig + def _render_raw(self, frame: Any, spec: FigureSpec) -> Any: + """Render raw time-series figures (#647).""" + import matplotlib.pyplot as plt + + extras = dict(spec.extras or {}) + if extras.get("unsupported_plot_type"): + return None + + kwargs = dict(extras.get("additional_kwargs") or {}) + x = extras["x"] + x_label = extras.get("x_label") or (spec.x_axis.label or x) + y = list(extras["y"]) + y_label = list(extras["y_label"]) + title = spec.title + double_y = bool(extras.get("double_y", True)) + number_of_rows = len(y) + xlim = kwargs.get("xlim") + figsize = kwargs.pop("figsize", (10, 2 * number_of_rows)) + + if _seaborn_available(): + import seaborn as sns + + if double_y: + sns.set_style(kwargs.pop("style", "dark")) + else: + sns.set_style(kwargs.pop("style", "darkgrid")) + + if len(y) == 1: + fig, ax = plt.subplots(figsize=figsize) + ax.plot(frame[x], frame[y[0]]) + ax.set_xlabel(x_label) + ax.set_ylabel(y_label[0]) + ax.set_title(title) + ax.set_xlim(xlim) + return fig + + if len(y) == 2 and double_y: + fig, ax_v = plt.subplots(figsize=figsize) + color = "tab:red" + ax_v.set_xlabel(x_label) + ax_v.set_ylabel(y_label[0], color=color) + ax_v.plot(frame[x], frame[y[0]], label=y_label[0], color=color) + ax_v.tick_params(axis="y", labelcolor=color) + ax_c = ax_v.twinx() + color = "tab:blue" + ax_c.set_ylabel(y_label[1], color=color) + ax_c.plot(frame[x], frame[y[1]], label=y_label[1], color=color) + ax_c.tick_params(axis="y", labelcolor=color) + ax_v.set_xlim(xlim) + fig.tight_layout() + return fig + + fig, axes = plt.subplots( + nrows=number_of_rows, ncols=1, figsize=figsize, sharex=True + ) + for i in range(number_of_rows): + axes[i].plot(frame[x], frame[y[i]]) + axes[i].set_ylabel(y_label[i]) + axes[0].set_title(title) + axes[0].set_xlim(xlim) + axes[-1].set_xlabel(x_label) + fig.align_ylabels() + fig.tight_layout() + return fig + + def _render_cycle_info(self, frame: Any, spec: FigureSpec) -> Any: + """Render cycle-info overlay figures (#647).""" + import itertools + + import matplotlib.pyplot as plt + + from cellpy.plotting.labels import quantity_label + + extras = dict(spec.extras or {}) + kwargs = dict(extras.get("additional_kwargs") or {}) + cycle = extras["cycle"] + get_axes = bool(extras.get("get_axes", False)) + t_unit = extras["t_unit"] + v_unit = extras["v_unit"] + i_unit = extras["i_unit"] + i_scaler = extras["i_scaler"] + time_hdr = extras["time_hdr"] + step_number_hdr = extras["step_number_hdr"] + current_hdr = extras["current_hdr"] + voltage_hdr = extras["voltage_hdr"] + table = extras["steps"] + step_hdr = extras["step_hdr"] + + span_colors = ["#4682B4", "#FFA07A"] + voltage_color = "#008B8B" + current_color = "#CD5C5C" + all_steps = frame[step_number_hdr].unique() + color = itertools.cycle(span_colors) + + fig = plt.figure(figsize=(20, 8)) + fig.suptitle(spec.title or f"Cycle: {cycle}") + ax3 = plt.subplot2grid((8, 3), (0, 0), colspan=3, rowspan=1, fig=fig) + ax4 = plt.subplot2grid((8, 3), (1, 0), colspan=3, rowspan=2, fig=fig) + ax1 = plt.subplot2grid((8, 3), (3, 0), colspan=3, rowspan=5, fig=fig) + ax2 = ax1.twinx() + ax1.set_xlabel(spec.x_axis.label or quantity_label("time", t_unit)) + ax1.set_ylabel( + (spec.panels[0].y_axis.label if spec.panels else quantity_label("voltage", v_unit)), + color=voltage_color, + ) + ax2.set_ylabel(quantity_label("current", i_unit), color=current_color) + + annotations_1 = [] + annotations_2 = [] + annotations_4 = [] + + for s in all_steps: + m = frame[step_number_hdr] == s + c_vals = frame.loc[m, current_hdr] + v_vals = frame.loc[m, voltage_hdr] + t_vals = frame.loc[m, time_hdr] + step_type, rate, current_max, dv, dc, d_discharge, d_charge = _get_step_info( + table, cycle, s, step_hdr + ) + if len(t_vals) > 1: + fcolor = next(color) + info_txt = f"{step_type}\ni = |{i_scaler * current_max:0.2f}| {i_unit}\n" + info_txt += f"delta V = {dv:0.2f} %\ndelta i = {dc:0.2f} %\n" + info_txt += f"delta C = {d_charge:0.2} %\ndelta DC = {d_discharge:0.2} %\n" + for ax in [ax2, ax3, ax4]: + ax.axvspan( + t_vals.iloc[0], t_vals.iloc[-1], facecolor=fcolor, alpha=0.2 + ) + ax1.plot(t_vals, v_vals, color=voltage_color, linewidth=3) + ax2.plot(t_vals, c_vals, color=current_color, linewidth=3) + annotations_1.append([f"{s}", t_vals.mean()]) + annotations_4.append([info_txt, t_vals.mean()]) + else: + info_txt = f"{s}({step_type})" + annotations_2.append([info_txt, t_vals.mean()]) + + ax3.set_ylim(0, 1) + for s in annotations_1: + ax3.annotate(f"{s[0]}", (s[1], 0.2), ha="center") + for s in annotations_2: + ax3.annotate(f"{s[0]}", (s[1], 0.6), ha="center") + for s in annotations_4: + ax4.annotate(f"{s[0]}", (s[1], 0.0), ha="center") + for ax in [ax3, ax4]: + ax.axes.get_yaxis().set_visible(False) + ax.axes.get_xaxis().set_visible(False) + + if x := kwargs.get("xlim"): + ax1.set_xlim(x) + ax2.set_xlim(x) + ax3.set_xlim(x) + ax4.set_xlim(x) + + if get_axes: + return ax1, ax2, ax2, ax4 + return fig + + +def _get_step_info(table, cycle, step, step_hdr): + """Step-table annotation values for cycle-info matplotlib render (#647).""" + m_table = (table[step_hdr.cycle] == cycle) & (table[step_hdr.step] == step) + c1, c2 = ( + table.loc[ + m_table, + [step_hdr.stat("current", "min"), step_hdr.stat("current", "max")], + ] + .abs() + .values[0] + ) + d_voltage, d_current = table.loc[ + m_table, [step_hdr.stat("voltage", "delta"), step_hdr.stat("current", "delta")] + ].values[0] + d_discharge, d_charge = table.loc[ + m_table, + [step_hdr.stat("discharge", "delta"), step_hdr.stat("charge", "delta")], + ].values[0] + current_max = (c1 + c2) / 2 + rate = table.loc[m_table, step_hdr.rate_avr].values[0] + step_type = table.loc[m_table, step_hdr.type].values[0] + return [step_type, rate, current_max, d_voltage, d_current, d_discharge, d_charge] diff --git a/cellpy/plotting/backends/plotly.py b/cellpy/plotting/backends/plotly.py index b32cc841..49138545 100644 --- a/cellpy/plotting/backends/plotly.py +++ b/cellpy/plotting/backends/plotly.py @@ -296,8 +296,13 @@ def __init__(self) -> None: def render(self, frame: Any, spec: FigureSpec) -> Any: extras = dict(spec.extras or {}) - if extras.get("kind") == "cycles": + kind = extras.get("kind") + if kind == "cycles": return self._render_cycles(frame, spec) + if kind == "raw": + return self._render_raw(frame, spec) + if kind == "cycle_info": + return self._render_cycle_info(frame, spec) import plotly.express as px @@ -711,3 +716,137 @@ def _render_cycles(self, frame: Any, spec: FigureSpec) -> Any: plotly_layout_kwargs = kwargs.pop("plotly_layout_kwargs", {}) fig.update_layout(height=height, width=width, **plotly_layout_kwargs) return fig + + def _render_raw(self, frame: Any, spec: FigureSpec) -> Any: + """Render raw time-series figures (#647).""" + import plotly.express as px + import plotly.graph_objects as go + from plotly.subplots import make_subplots + + extras = dict(spec.extras or {}) + if extras.get("unsupported_plot_type"): + return None + + kwargs = dict(extras.get("additional_kwargs") or {}) + x = extras["x"] + x_label = extras.get("x_label") or (spec.x_axis.label or x) + y = list(extras["y"]) + y_label = list(extras["y_label"]) + title = spec.title or "" + if not title.startswith(""): + title = f"{title}" + special_height = extras.get("special_height") + number_of_rows = len(y) + + if number_of_rows == 1: + labels = {} + if x_label: + labels[x] = x_label + if y_label: + labels[y[0]] = y_label[0] + return px.line( + frame, + x=x, + y=y[0], + title=title, + labels=labels or None, + **kwargs, + ) + + width = kwargs.pop("width", 1000) + height = kwargs.pop("height", None) + if height is None: + height = special_height if special_height is not None else number_of_rows * 300 + vertical_spacing = kwargs.pop("vertical_spacing", 0.02) + fig = make_subplots( + rows=number_of_rows, + cols=1, + shared_xaxes=True, + vertical_spacing=vertical_spacing, + x_title=x_label, + ) + x_values = frame[x] + for i in range(number_of_rows): + fig.add_trace( + go.Scatter(x=x_values, y=frame[y[i]], name=y_label[i]), + row=i + 1, + col=1, + **kwargs, + ) + fig.update_layout(height=height, width=width, title_text=title) + return fig + + def _render_cycle_info(self, frame: Any, spec: FigureSpec) -> Any: + """Render cycle-info overlay figures (#647).""" + import numpy as np + import plotly.graph_objects as go + + extras = dict(spec.extras or {}) + kwargs = dict(extras.get("additional_kwargs") or {}) + cycle_hdr = extras["cycle_hdr"] + time_hdr = extras["time_hdr"] + voltage_hdr = extras["voltage_hdr"] + step_number_hdr = extras["step_number_hdr"] + current_hdr = extras["current_hdr"] + type_ = extras["type_hdr"] + v_delta = extras["v_delta"] + i_delta = extras["i_delta"] + c_delta = extras["c_delta"] + dc_delta = extras["dc_delta"] + t_unit = extras["t_unit"] + v_unit = extras["v_unit"] + i_unit = extras["i_unit"] + + if kwargs.get("xlim"): + logging.info("xlim is not supported for plotly yet") + + fig = go.Figure() + for cycle_number, group in frame.groupby(cycle_hdr): + fig.add_trace( + go.Scatter( + x=group[time_hdr], + y=group[voltage_hdr], + mode="lines", + name=f"cycle {cycle_number}", + customdata=np.stack( + ( + group[current_hdr], + group[step_number_hdr], + group[type_], + group[v_delta], + group[i_delta], + group[c_delta], + group[dc_delta], + ), + axis=-1, + ), + hovertemplate="
".join( + [ + "Time: %{x:.2f}" + f" {t_unit}" + "", + " Voltage: %{y:.4f}" + f" {v_unit}", + " Current: %{customdata[0]:.4f}" + f" {i_unit}", + "Step: %{customdata[1]} (%{customdata[2]})", + " ΔV: %{customdata[3]:.2f}", + " ΔI: %{customdata[4]:.2f}", + " ΔCh: %{customdata[5]:.2f}", + " ΔDCh: %{customdata[6]:.2f}", + ] + ), + ), + ) + + height = kwargs.get("height", 600) + width = kwargs.get("width", 1000) + y_title = ( + spec.panels[0].y_axis.label + if spec.panels + else f"Voltage ({v_unit})" + ) + fig.update_layout( + title=spec.title, + xaxis_title=spec.x_axis.label or f"Time ({t_unit})", + yaxis_title=y_title, + width=width, + height=height, + ) + return fig diff --git a/cellpy/plotting/headers.py b/cellpy/plotting/headers.py new file mode 100644 index 00000000..046d5b9a --- /dev/null +++ b/cellpy/plotting/headers.py @@ -0,0 +1,73 @@ +"""Per-cell frame header resolution for plotting (#567 / #647). + +Replaces module-level ``get_headers_*()`` singletons that always answered with +legacy names after the native-headers flip. +""" + +from __future__ import annotations + +from cellpycore.legacy import mapping + +#: cellpy-core mapping key per frame. +_MAPPING_FRAME = {"raw": "raw", "steps": "step", "summary": "cycle"} + + +class LiveHeaders: + """Column names for *this cell's* frames, keyed by the 1.x attribute names. + + Resolution goes through the cell's own schema, so this is correct on both + runtimes: on the legacy runtime ``schema.raw.potential`` still answers + ``"voltage"``. + + Both spellings the old singletons supported are kept, because call sites + use both: ``hdr["voltage_txt"]`` and ``hdr.voltage_txt``. + """ + + __slots__ = ("_schema", "_frame", "_attrs") + + def __init__(self, c, frame: str): + self._frame = frame + self._schema = getattr(c.schema, frame) + self._attrs = mapping.LEGACY_ATTR_TO_SCHEMA[_MAPPING_FRAME[frame]] + + def _resolve(self, legacy_attr: str) -> str: + native = self._attrs.get(legacy_attr) + if native is None: + try: + return getattr(self._schema, legacy_attr) + except AttributeError: + raise KeyError( + f"no {self._frame} column named {legacy_attr!r} on this cell" + ) from None + return getattr(self._schema, native) + + def base(self, legacy_attr: str) -> str: + """The native *stem* of a step-table statistic family. + + Step-table columns are ``_`` (``potential_delta``, + ``current_min``). The schema exposes the composed columns, not the + stem, so this reads the stem straight off the mapping — and falls back + to the legacy spelling on the legacy runtime, where the stem is what + the frame already uses. + """ + native = self._attrs.get(legacy_attr) + if native is None: + return legacy_attr + try: + return getattr(self._schema, native) + except AttributeError: + return native + + def stat(self, legacy_attr: str, statistic: str) -> str: + """``stat("voltage", "delta")`` -> ``"potential_delta"`` (native).""" + return f"{self.base(legacy_attr)}_{statistic}" + + def __getitem__(self, legacy_attr: str) -> str: + return self._resolve(legacy_attr) + + def __getattr__(self, legacy_attr: str) -> str: + return self._resolve(legacy_attr) + + +# Historical private name used by plotutils / tests (#567). +_LiveHeaders = LiveHeaders diff --git a/cellpy/plotting/labels.py b/cellpy/plotting/labels.py index 3e289bf5..8ec79455 100644 --- a/cellpy/plotting/labels.py +++ b/cellpy/plotting/labels.py @@ -1,6 +1,6 @@ -"""Legend and marker post-processing for plotly traces — one implementation (#567). +"""Legend, marker, and axis-label helpers for plotting (#567 / #647). -These two helpers existed in **three** places, under two naming conventions: +Legend/marker helpers existed in **three** places, under two naming conventions: | | plotutils | collectors | batch_plotters | |---|---|---|---| @@ -12,17 +12,61 @@ the batch_plotters copy carried an extra ``inverted_mode`` that swaps group and sub-group. That copy is therefore the superset, and it is the one kept — with ``inverted_mode=False`` as the default, which reproduces the other two exactly. + +Axis labels for ``raw_plot`` / ``cycle_info_plot`` go through +:func:`quantity_label` / :func:`units_quantity_label` so those paths do not +hand-compose ``f"{name} ({unit})"`` strings (#647). """ from __future__ import annotations import logging +from typing import TYPE_CHECKING, Optional from cellpy.parameters.internal_settings import get_headers_journal +if TYPE_CHECKING: + from cellpycore.units.spec import CellpyUnits + hdr_journal = get_headers_journal() +def quantity_label(name: str, unit: str) -> str: + """Compose an axis label ``name (unit)``. + + Args: + name: human-readable quantity name, e.g. ``"Voltage"``. + unit: unit string already resolved for the plotted series. + + Returns: + e.g. ``"Voltage (V)"``, ``"Time (hours)"``. + """ + return f"{name} ({unit})" + + +def units_quantity_label( + name: str, + physical_property: str, + mode: Optional[str] = None, + *, + units: Optional["CellpyUnits"] = None, +) -> str: + """Axis label via :func:`cellpy.units.units_label`. + + Args: + name: human-readable quantity name. + physical_property: as :func:`~cellpy.units.units_label`. + mode: as :func:`~cellpy.units.units_label`. + units: unit spec for the series (e.g. ``cell.data.raw_units``). + + Returns: + e.g. ``"Voltage (V)"``, ``"Charge capacity (Ah)"``. + """ + from cellpy.units import with_cellpy_unit + + return with_cellpy_unit(name, physical_property, mode, units=units) + + def remove_markers(trace): """Turn a plotly trace into a plain line.""" trace.update(marker=None, mode="lines") diff --git a/cellpy/plotting/prepare/__init__.py b/cellpy/plotting/prepare/__init__.py index 5e33c4ff..720ed6fa 100644 --- a/cellpy/plotting/prepare/__init__.py +++ b/cellpy/plotting/prepare/__init__.py @@ -1,8 +1,10 @@ -"""Prepare stages for the prepare → spec → render pipeline (#638 / #646).""" +"""Prepare stages for the prepare → spec → render pipeline (#638 / #646 / #647).""" from __future__ import annotations from cellpy.plotting.prepare.curves import prepare as prepare_curves +from cellpy.plotting.prepare.raw import prepare as prepare_raw +from cellpy.plotting.prepare.steps import prepare as prepare_cycle_info from cellpy.plotting.prepare.summary import prepare as prepare_summary -__all__ = ["prepare_curves", "prepare_summary"] +__all__ = ["prepare_curves", "prepare_cycle_info", "prepare_raw", "prepare_summary"] diff --git a/cellpy/plotting/prepare/raw.py b/cellpy/plotting/prepare/raw.py new file mode 100644 index 00000000..ca9c0c76 --- /dev/null +++ b/cellpy/plotting/prepare/raw.py @@ -0,0 +1,190 @@ +"""Raw-plot prepare path: raw frame + FigureSpec (#647). + +Public ``raw_plot`` calls :func:`prepare` then hands ``(frame, FigureSpec)`` +to a backend renderer (``spec.extras['kind'] == 'raw'``). +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field +from typing import Any, Optional + +import pandas as pd + +from cellpy.plotting.headers import LiveHeaders +from cellpy.plotting.labels import quantity_label, units_quantity_label +from cellpy.plotting.spec import AxisSpec, FigureSpec, PanelSpec + + +@dataclass +class RawPrepareConfig: + """Input knobs for :func:`prepare` (mirrors public ``raw_plot`` args).""" + + y: Optional[Any] = None + y_label: Optional[Any] = None + x: Optional[str] = None + x_label: Optional[str] = None + title: Optional[str] = None + plot_type: str = "voltage-current" + double_y: bool = True + backend: str = "plotly" + additional_kwargs: dict = field(default_factory=dict) + + +def prepare( + ctx: Any, + family: Any, + config: RawPrepareConfig, +) -> tuple[pd.DataFrame, FigureSpec]: + """Prepare a raw frame and :class:`FigureSpec` for ``raw_plot``. + + Args: + ctx: :class:`~cellpy.plotting.context.CellContext` (or compatible). + family: registered :class:`~cellpy.plotting.registry.PlotFamily` (``raw``). + config: :class:`RawPrepareConfig` built by the public entry point. + + Returns: + ``(frame, spec)`` where ``spec.extras['kind'] == 'raw'``. + """ + from cellpy.readers.data_structures import Q + + c = ctx.cell + hdr_raw = LiveHeaders(c, "raw") + raw = c.data.raw.copy() + raw_units = c.data.raw_units + + y = config.y + y_label = config.y_label + x = config.x + x_label = config.x_label + title = config.title + plot_type = config.plot_type + special_height = None + + if y is not None: + if y_label is None: + y_label = y + y = [y] + y_label = [y_label] + elif plot_type is not None: + if plot_type == "voltage-current": + y = [hdr_raw["voltage_txt"], hdr_raw["current_txt"]] + y_label = [ + units_quantity_label("Voltage", "voltage", units=raw_units), + units_quantity_label("Current", "current", units=raw_units), + ] + elif plot_type == "capacity": + y = [ + hdr_raw["charge_capacity_txt"], + hdr_raw["discharge_capacity_txt"], + ] + y_label = [ + units_quantity_label("Charge capacity", "charge", units=raw_units), + units_quantity_label("Discharge capacity", "charge", units=raw_units), + ] + elif plot_type == "raw": + y = [ + hdr_raw["cycle_index_txt"], + hdr_raw["step_index_txt"], + hdr_raw["voltage_txt"], + hdr_raw["current_txt"], + ] + y_label = [ + quantity_label("Cycle index", "#"), + quantity_label("Step index", "#"), + units_quantity_label("Voltage", "voltage", units=raw_units), + units_quantity_label("Current", "current", units=raw_units), + ] + special_height = 600 + elif plot_type == "capacity-current": + y = [ + hdr_raw["charge_capacity_txt"], + hdr_raw["discharge_capacity_txt"], + hdr_raw["current_txt"], + ] + y_label = [ + units_quantity_label("Charge capacity", "charge", units=raw_units), + units_quantity_label("Discharge capacity", "charge", units=raw_units), + units_quantity_label("Current", "current", units=raw_units), + ] + special_height = 500 + elif plot_type == "full": + y = [ + hdr_raw["voltage_txt"], + hdr_raw["current_txt"], + hdr_raw["charge_capacity_txt"], + hdr_raw["discharge_capacity_txt"], + hdr_raw["cycle_index_txt"], + hdr_raw["step_index_txt"], + ] + y_label = [ + units_quantity_label("Voltage", "voltage", units=raw_units), + units_quantity_label("Current", "current", units=raw_units), + units_quantity_label("Charge capacity", "charge", units=raw_units), + units_quantity_label("Discharge capacity", "charge", units=raw_units), + quantity_label("Cycle index", "#"), + quantity_label("Step index", "#"), + ] + special_height = 800 + else: + warnings.warn(f"Plot type {plot_type} not supported") + empty = pd.DataFrame() + return empty, FigureSpec( + title=title, + extras={"kind": "raw", "unsupported_plot_type": plot_type}, + ) + else: + y = [hdr_raw["voltage_txt"]] + y_label = [units_quantity_label("Voltage", "voltage", units=raw_units)] + + if x is None: + x = "test_time_hrs" + + if x in ["test_time_hrs", "test_time_hours"]: + conv_factor = Q(c.raw_units.time).to("hours").magnitude + raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor + x_label = x_label or quantity_label("Time", "hours") + elif x == "test_time_days": + conv_factor = Q(c.raw_units.time).to("days").magnitude + raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor + x_label = x_label or quantity_label("Time", "days") + elif x == "test_time_years": + conv_factor = Q(c.raw_units.time).to("years").magnitude + raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor + x_label = x_label or quantity_label("Time", "years") + + if title is None: + title = f"{c.cell_name}" + + y = list(y) + y_label = list(y_label) + panels = tuple( + PanelSpec( + columns=(col,), + kind="line", + y_axis=AxisSpec(label=lab), + ) + for col, lab in zip(y, y_label) + ) + family_name = getattr(family, "name", "raw") + spec = FigureSpec( + panels=panels, + x_axis=AxisSpec(label=x_label), + title=title, + supports_formation=False, + extras={ + "kind": "raw", + "family": family_name, + "x": x, + "x_label": x_label, + "y": y, + "y_label": y_label, + "double_y": config.double_y, + "special_height": special_height, + "backend": config.backend, + "cell": c, + "additional_kwargs": dict(config.additional_kwargs), + }, + ) + return raw, spec diff --git a/cellpy/plotting/prepare/steps.py b/cellpy/plotting/prepare/steps.py new file mode 100644 index 00000000..1f4d3778 --- /dev/null +++ b/cellpy/plotting/prepare/steps.py @@ -0,0 +1,203 @@ +"""Cycle-info prepare path: scaled raw+steps frame + FigureSpec (#647). + +Public ``cycle_info_plot`` calls :func:`prepare` then hands ``(frame, FigureSpec)`` +to a backend renderer (``spec.extras['kind'] == 'cycle_info'``). +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field +from typing import Any, Optional + +import pandas as pd + +from cellpy.plotting.headers import LiveHeaders +from cellpy.plotting.labels import quantity_label +from cellpy.plotting.spec import AxisSpec, FigureSpec, PanelSpec + + +@dataclass +class CycleInfoPrepareConfig: + """Input knobs for :func:`prepare` (mirrors public ``cycle_info_plot`` args).""" + + cycle: Optional[Any] = None + get_axes: bool = False + t_unit: str = "hours" + v_unit: str = "V" + i_unit: str = "mA" + backend: str = "plotly" + additional_kwargs: dict = field(default_factory=dict) + + +def prepare( + ctx: Any, + family: Any, + config: CycleInfoPrepareConfig, +) -> tuple[pd.DataFrame, FigureSpec]: + """Prepare scaled raw/steps data and :class:`FigureSpec` for ``cycle_info_plot``. + + Args: + ctx: :class:`~cellpy.plotting.context.CellContext` (or compatible). + family: registered family (``cycle_info``). + config: :class:`CycleInfoPrepareConfig` built by the public entry point. + + Returns: + ``(frame, spec)`` where ``spec.extras['kind'] == 'cycle_info'``. + + For plotly, *frame* is the merged scaled raw+steps table used for traces. + For matplotlib, *frame* is the scaled raw subset; the step table lives in + ``spec.extras['steps']``. + """ + c = ctx.cell + raw_hdr = LiveHeaders(c, "raw") + step_hdr = LiveHeaders(c, "steps") + + t_scaler = c.unit_scaler_from_raw(config.t_unit, "time") + v_scaler = c.unit_scaler_from_raw(config.v_unit, "voltage") + i_scaler = c.unit_scaler_from_raw(config.i_unit, "current") + + cycle = config.cycle + backend = config.backend + + # Matplotlib keeps the historical single-cycle asymmetry (epic #567). + if backend == "matplotlib": + if cycle is None: + warnings.warn("Only one cycle at a time is supported for matplotlib") + cycle = 1 + if isinstance(cycle, (list, tuple)): + warnings.warn("Only one cycle at a time is supported for matplotlib") + cycle = cycle[0] + + data = c.data.raw.copy() + table = c.data.steps.copy() + + time_hdr = raw_hdr.test_time_txt + cycle_hdr = raw_hdr.cycle_index_txt + step_number_hdr = raw_hdr.step_index_txt + current_hdr = raw_hdr.current_txt + voltage_hdr = raw_hdr.voltage_txt + + if backend == "plotly": + if cycle is None: + cycle = list(data[cycle_hdr].unique()) + if not isinstance(cycle, (list, tuple)): + cycle = [cycle] + + v_delta = step_hdr.stat("voltage", "delta") + i_delta = step_hdr.stat("current", "delta") + c_delta = step_hdr.stat("charge", "delta") + dc_delta = step_hdr.stat("discharge", "delta") + cycle_ = step_hdr.cycle + step_ = step_hdr.step + type_ = step_hdr.type + + data = data[ + [time_hdr, cycle_hdr, step_number_hdr, current_hdr, voltage_hdr] + ] + table = table[ + [cycle_, step_, type_, v_delta, i_delta, c_delta, dc_delta] + ] + data = data.loc[data[cycle_hdr].isin(cycle), :] + data[time_hdr] = data[time_hdr] * t_scaler + data[voltage_hdr] = data[voltage_hdr] * v_scaler + data[current_hdr] = data[current_hdr] * i_scaler + frame = data.merge( + table, + left_on=(cycle_hdr, step_number_hdr), + right_on=(cycle_, step_), + ).sort_values(by=[time_hdr]) + + title = _plotly_title(c, cycle, config.additional_kwargs) + family_name = getattr(family, "name", "cycle_info") + spec = FigureSpec( + panels=( + PanelSpec( + columns=(voltage_hdr,), + kind="line", + y_axis=AxisSpec(label=quantity_label("Voltage", config.v_unit)), + ), + ), + x_axis=AxisSpec(label=quantity_label("Time", config.t_unit)), + title=title, + supports_formation=False, + extras={ + "kind": "cycle_info", + "family": family_name, + "cycle": list(cycle), + "get_axes": config.get_axes, + "t_unit": config.t_unit, + "v_unit": config.v_unit, + "i_unit": config.i_unit, + "time_hdr": time_hdr, + "cycle_hdr": cycle_hdr, + "step_number_hdr": step_number_hdr, + "current_hdr": current_hdr, + "voltage_hdr": voltage_hdr, + "type_hdr": type_, + "v_delta": v_delta, + "i_delta": i_delta, + "c_delta": c_delta, + "dc_delta": dc_delta, + "backend": backend, + "cell": c, + "additional_kwargs": dict(config.additional_kwargs), + }, + ) + return frame, spec + + # matplotlib path: scaled columns stay on the raw subset; steps for annotations. + m_cycle_data = data[cycle_hdr] == cycle + frame = data.loc[m_cycle_data].copy() + frame[time_hdr] = frame[time_hdr] * t_scaler + frame[voltage_hdr] = frame[voltage_hdr] * v_scaler + frame[current_hdr] = frame[current_hdr] * i_scaler + + family_name = getattr(family, "name", "cycle_info") + spec = FigureSpec( + panels=( + PanelSpec( + columns=(voltage_hdr, current_hdr), + kind="line", + y_axis=AxisSpec(label=quantity_label("voltage", config.v_unit)), + ), + ), + x_axis=AxisSpec(label=quantity_label("time", config.t_unit)), + title=f"Cycle: {cycle}", + supports_formation=False, + extras={ + "kind": "cycle_info", + "family": family_name, + "cycle": cycle, + "get_axes": config.get_axes, + "t_unit": config.t_unit, + "v_unit": config.v_unit, + "i_unit": config.i_unit, + "t_scaler": t_scaler, + "v_scaler": v_scaler, + "i_scaler": i_scaler, + "time_hdr": time_hdr, + "cycle_hdr": cycle_hdr, + "step_number_hdr": step_number_hdr, + "current_hdr": current_hdr, + "voltage_hdr": voltage_hdr, + "steps": table, + "step_hdr": step_hdr, + "backend": backend, + "cell": c, + "additional_kwargs": dict(config.additional_kwargs), + }, + ) + return frame, spec + + +def _plotly_title(c: Any, cycle: list, kwargs: dict) -> str: + cell_name = kwargs.get("title", c.cell_name) + title_start = f"{cell_name} Cycle" + if len(cycle) > 2: + if cycle[-1] - cycle[0] == len(cycle) - 1: + return f"{title_start}s {cycle[0]} - {cycle[-1]}" + return f"{title_start}s {cycle}" + if len(cycle) == 2: + return f"{title_start}s {cycle[0]} and {cycle[1]}" + return f"{title_start} {cycle[0]}" diff --git a/cellpy/plotting/registry.py b/cellpy/plotting/registry.py index c6dd7a97..38890f23 100644 --- a/cellpy/plotting/registry.py +++ b/cellpy/plotting/registry.py @@ -294,6 +294,21 @@ def _register_builtin_families() -> None: supports_formation=True, extras={"entry_point": "cycles_plot", "kind": "cycles"}, ), + # Stage 2 (#647): raw time-series and cycle-info overlays. + PlotFamily( + name="raw", + description="Raw time-series traces", + column_builder=lambda hdr: ["voltage", "current"], + supports_formation=False, + extras={"entry_point": "raw_plot", "kind": "raw"}, + ), + PlotFamily( + name="cycle_info", + description="Raw traces with step/cycle annotations", + column_builder=lambda hdr: ["test_time", "voltage", "current"], + supports_formation=False, + extras={"entry_point": "cycle_info_plot", "kind": "cycle_info"}, + ), ] for family in builtins: _register_family(family) diff --git a/cellpy/utils/plotutils.py b/cellpy/utils/plotutils.py index c1c4dd9d..861810d7 100644 --- a/cellpy/utils/plotutils.py +++ b/cellpy/utils/plotutils.py @@ -46,6 +46,7 @@ from cellpy.plotting.labels import legend_replacer as _plotly_legend_replacer from cellpy.plotting.labels import remove_markers as _plotly_remove_markers from cellpy.plotting import registry as plot_registry +from cellpy.plotting.headers import LiveHeaders as _LiveHeaders from cellpy.plotting.theme import make_plotly_template as _make_plotly_template plotly_available = importlib.util.find_spec("plotly") is not None @@ -544,82 +545,10 @@ def _resolve_summary_columns(c, names): return [_resolve_summary_column(c, name) for name in names] -#: cellpy-core mapping key per frame, for ``_LiveHeaders``. +#: Prefer ``cellpy.plotting.headers.LiveHeaders`` (#647). _MAPPING_FRAME = {"raw": "raw", "steps": "step", "summary": "cycle"} -class _LiveHeaders: - """Column names for *this cell's* frames, keyed by the 1.x attribute names. - - Replaces the module-level ``_hdr_raw = get_headers_normal()`` / - ``_hdr_steps`` / ``_hdr_summary`` singletons. Those were built once at - import time and always answered with the **legacy** names, so after the - native-headers flip every lookup through them produced a key the frame no - longer had. That is why ``raw_plot`` and ``cycle_info_plot`` raised - ``KeyError: 'voltage'`` on any cellpy 2 cell — a module-level binding that - could not see the runtime, which is the same trap that bit the schema - migration in #558. - - Resolution goes through the cell's own schema, so this is correct on both - runtimes: on the legacy runtime ``schema.raw.potential`` still answers - ``"voltage"``. - - Both spellings the old singletons supported are kept, because call sites - use both: ``hdr["voltage_txt"]`` and ``hdr.voltage_txt``. - """ - - __slots__ = ("_schema", "_frame", "_attrs") - - def __init__(self, c, frame: str): - self._frame = frame - self._schema = getattr(c.schema, frame) - self._attrs = mapping.LEGACY_ATTR_TO_SCHEMA[_MAPPING_FRAME[frame]] - - def _resolve(self, legacy_attr: str) -> str: - native = self._attrs.get(legacy_attr) - if native is None: - # Either unknown, or a legacy-only column with no native - # counterpart. Fall back to the schema's own attribute so native - # spellings work too, and let it raise if that fails as well. - try: - return getattr(self._schema, legacy_attr) - except AttributeError: - raise KeyError( - f"no {self._frame} column named {legacy_attr!r} on this cell" - ) from None - return getattr(self._schema, native) - - def base(self, legacy_attr: str) -> str: - """The native *stem* of a step-table statistic family. - - Step-table columns are ``_`` (``potential_delta``, - ``current_min``). The schema exposes the composed columns, not the - stem, so this reads the stem straight off the mapping — and falls back - to the legacy spelling on the legacy runtime, where the stem is what - the frame already uses. - """ - native = self._attrs.get(legacy_attr) - if native is None: - return legacy_attr - # On the legacy runtime the schema resolves the native name back to the - # legacy one; when it cannot (a stem is not a column) keep the native - # stem, which is what a native frame is built from. - try: - return getattr(self._schema, native) - except AttributeError: - return native - - def stat(self, legacy_attr: str, statistic: str) -> str: - """``stat("voltage", "delta")`` -> ``"potential_delta"`` (native).""" - return f"{self.base(legacy_attr)}_{statistic}" - - def __getitem__(self, legacy_attr: str) -> str: - return self._resolve(legacy_attr) - - def __getattr__(self, legacy_attr: str) -> str: - return self._resolve(legacy_attr) - - # Per-row y-axis labels for predefined ``y`` sets that route a different # quantity onto row 0 (efficiency plots, *_with_rate plots). The "_plotly" # and "_seaborn" variants differ only in the line-break character (HTML @@ -1449,13 +1378,16 @@ def raw_plot( x=None, x_label=None, title=None, - interactive=True, + backend: Optional[str] = None, + interactive: Optional[bool] = None, plot_type="voltage-current", double_y=True, **kwargs, ): """Plot raw data. + Draws through prepare → spec → render (#647). + Args: cell: cellpy object y (str or list): y-axis column @@ -1463,7 +1395,9 @@ def raw_plot( x (str): x-axis column x_label (str): label for x-axis title (str): title of the plot - interactive (bool): use interactive plotting + backend (str, optional): ``"plotly"`` (default) or ``"matplotlib"``. + interactive (bool, optional): Deprecated alias for backend selection + (``True``→plotly, ``False``→matplotlib; removal 2.1). plot_type (str): type of plot (defaults to "voltage-current") (overrides given y if y is not None), currently only "voltage-current", "raw", "capacity", "capacity-current", and "full" is supported. double_y (bool): use double y-axis (only for matplotlib and when plot_type with 2 rows is used) @@ -1473,242 +1407,45 @@ def raw_plot( ``matplotlib`` figure or ``plotly`` figure """ - from cellpy.readers.data_structures import Q - - _set_individual_y_labels = False - _special_height = None - - # Per-cell, not the module-level legacy singleton (#567). - hdr_raw = _LiveHeaders(cell, "raw") - raw = cell.data.raw.copy() - if y is not None: - if y_label is None: - y_label = y - y = [y] - y_label = [y_label] - - elif plot_type is not None: - # special pre-defined plot types - if plot_type == "voltage-current": - y1 = hdr_raw["voltage_txt"] - y1_label = f"Voltage ({cell.data.raw_units.voltage})" - y2 = hdr_raw["current_txt"] - y2_label = f"Current ({cell.data.raw_units.current})" - y = [y1, y2] - y_label = [y1_label, y2_label] - - elif plot_type == "capacity": - _y = [ - ( - hdr_raw["charge_capacity_txt"], - f"Charge capacity ({cell.data.raw_units.charge})", - ), - ( - hdr_raw["discharge_capacity_txt"], - f"Discharge capacity ({cell.data.raw_units.charge})", - ), - ] - y, y_label = zip(*_y) - - elif plot_type == "raw": - _y = [ - ( - hdr_raw["cycle_index_txt"], - f"Cycle index (#)", - ), - ( - hdr_raw["step_index_txt"], - f"Step index (#)", - ), - (hdr_raw["voltage_txt"], f"Voltage ({cell.data.raw_units.voltage})"), - (hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), - ] - y, y_label = zip(*_y) - _special_height = 600 - - elif plot_type == "capacity-current": - _y = [ - ( - hdr_raw["charge_capacity_txt"], - f"Charge capacity ({cell.data.raw_units.charge})", - ), - ( - hdr_raw["discharge_capacity_txt"], - f"Discharge capacity ({cell.data.raw_units.charge})", - ), - (hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), - ] - y, y_label = zip(*_y) - _special_height = 500 - - elif plot_type == "full": - _y = [ - (hdr_raw["voltage_txt"], f"Voltage ({cell.data.raw_units.voltage})"), - (hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), - ( - hdr_raw["charge_capacity_txt"], - f"Charge capacity ({cell.data.raw_units.charge})", - ), - ( - hdr_raw["discharge_capacity_txt"], - f"Discharge capacity ({cell.data.raw_units.charge})", - ), - ( - hdr_raw["cycle_index_txt"], - f"Cycle index (#)", - ), - ( - hdr_raw["step_index_txt"], - f"Step index (#)", - ), - ] - y, y_label = zip(*_y) - _special_height = 800 - - else: - warnings.warn(f"Plot type {plot_type} not supported") - return None - else: - # default to voltage if y is not given - y = [hdr_raw["voltage_txt"]] - y_label = [f"Voltage ({cell.data.raw_units.voltage})"] - - if x is None: - x = "test_time_hrs" - - if x in ["test_time_hrs", "test_time_hours"]: - raw_time_unit = cell.raw_units.time - conv_factor = Q(raw_time_unit).to("hours").magnitude - raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor - x_label = x_label or "Time (hours)" - elif x == "test_time_days": - raw_time_unit = cell.raw_units.time - conv_factor = Q(raw_time_unit).to("days").magnitude - raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor - x_label = x_label or "Time (days)" - elif x == "test_time_years": - raw_time_unit = cell.raw_units.time - conv_factor = Q(raw_time_unit).to("years").magnitude - raw[x] = raw[hdr_raw["test_time_txt"]] * conv_factor - x_label = x_label or "Time (years)" - - if title is None: - title = f"{cell.cell_name}" - - number_of_rows = len(y) - - if plotly_available and interactive: - title = f"{title}" - if number_of_rows == 1: - # single plot - import plotly.express as px - - if x_label or y_label: - labels = {} - if x_label: - labels[x] = x_label - if y_label: - labels[y[0]] = y_label[0] - else: - labels = None - fig = px.line(raw, x=x, y=y[0], title=title, labels=labels, **kwargs) - - else: - from plotly.subplots import make_subplots - import plotly.graph_objects as go - - width = kwargs.pop("width", 1000) - height = kwargs.pop("height", None) - if height is None: - if _special_height is not None: - height = _special_height - else: - height = number_of_rows * 300 - - vertical_spacing = kwargs.pop("vertical_spacing", 0.02) - - fig = make_subplots( - rows=number_of_rows, - cols=1, - shared_xaxes=True, - vertical_spacing=vertical_spacing, - x_title=x_label, - # hoversubplots="axis", # only available in plotly 5.21 - ) - x_values = raw[x] - - rows = range(1, number_of_rows + 1) - for i in range(number_of_rows): - fig.add_trace( - go.Scatter(x=x_values, y=raw[y[i]], name=y_label[i]), - row=rows[i], - col=1, - **kwargs, - ) - - fig.update_layout(height=height, width=width, title_text=title) - if _set_individual_y_labels: - for i in range(number_of_rows): - fig.update_yaxes(title_text=y_label[i], row=rows[i], col=1) - - return fig - - # default to a simple matplotlib figure - xlim = kwargs.get("xlim") - figsize = kwargs.pop("figsize", (10, 2 * number_of_rows)) - if seaborn_available: - import seaborn as sns - - if double_y: - sns.set_style(kwargs.pop("style", "dark")) - else: - sns.set_style(kwargs.pop("style", "darkgrid")) - - if len(y) == 1: - y = y[0] - y_label = y_label[0] - fig, ax = plt.subplots(figsize=figsize) - ax.plot(raw[x], raw[y]) - ax.set_xlabel(x_label) - ax.set_ylabel(y_label) - ax.set_title(title) - ax.set_xlim(xlim) - plt.close(fig) - return fig - - elif len(y) == 2 and double_y: - fig, ax_v = plt.subplots(figsize=figsize) - - color = "tab:red" - ax_v.set_xlabel(x_label) - ax_v.set_ylabel(y_label[0], color=color) - ax_v.plot(raw[x], raw[y[0]], label=y_label[0], color=color) - ax_v.tick_params(axis="y", labelcolor=color) - - ax_c = ax_v.twinx() - - color = "tab:blue" - ax_c.set_ylabel(y_label[1], color=color) - ax_c.plot(raw[x], raw[y[1]], label=y_label[1], color=color) - ax_c.tick_params(axis="y", labelcolor=color) - ax_v.set_xlim(xlim) - else: - fig, axes = plt.subplots( - nrows=number_of_rows, ncols=1, figsize=figsize, sharex=True + resolved_backend = backend + if interactive is not None: + warn_once( + "raw_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", ) + if resolved_backend is None: + resolved_backend = "plotly" if interactive else "matplotlib" + if resolved_backend is None: + resolved_backend = "plotly" - for i in range(number_of_rows): - axes[i].plot(raw[x], raw[y[i]]) - axes[i].set_ylabel(y_label[i]) - - axes[0].set_title(title) - axes[0].set_xlim(xlim) - axes[-1].set_xlabel(x_label) + if resolved_backend == "plotly" and not plotly_available: + warnings.warn("Can not perform interactive plotting. Plotly is not available.") + resolved_backend = "matplotlib" - fig.align_ylabels() + from cellpy.plotting.backends import get_backend + from cellpy.plotting.context import from_source + from cellpy.plotting.prepare.raw import RawPrepareConfig, prepare as prepare_raw - fig.tight_layout() - plt.close(fig) + prep_config = RawPrepareConfig( + y=y, + y_label=y_label, + x=x, + x_label=x_label, + title=title, + plot_type=plot_type, + double_y=double_y, + backend=resolved_backend, + additional_kwargs=dict(kwargs), + ) + ctx = from_source(cell) + family = plot_registry.get("raw") + frame, spec = prepare_raw(ctx, family, prep_config) + if spec.extras.get("unsupported_plot_type"): + return None + fig = get_backend(resolved_backend).render(frame, spec) + if resolved_backend == "matplotlib" and fig is not None: + plt.close(fig) return fig @@ -1716,7 +1453,8 @@ def cycle_info_plot( cell, cycle=None, get_axes=False, - interactive=True, + backend: Optional[str] = None, + interactive: Optional[bool] = None, t_unit="hours", v_unit="V", i_unit="mA", @@ -1724,336 +1462,71 @@ def cycle_info_plot( ): """Show raw data together with step and cycle information. + Draws through prepare → spec → render (#647). + Args: cell: cellpy object cycle (int or list or tuple): cycle(s) to select (must be int for matplotlib) get_axes (bool): return axes (for matplotlib) or figure (for plotly) - interactive (bool): use interactive plotting (if available) + backend (str, optional): ``"plotly"`` (default) or ``"matplotlib"``. + interactive (bool, optional): Deprecated alias for backend selection + (``True``→plotly, ``False``→matplotlib; removal 2.1). t_unit (str): unit for x-axis (default: "hours") v_unit (str): unit for y-axis (default: "V") i_unit (str): unit for current (default: "mA") **kwargs: parameters specific to plotting backend. Returns: - ``matplotlib.axes`` or None + ``matplotlib.axes`` or None (or a figure when ``get_axes`` / backend semantics require it) """ - t_scaler = cell.unit_scaler_from_raw(t_unit, "time") - v_scaler = cell.unit_scaler_from_raw(v_unit, "voltage") - i_scaler = cell.unit_scaler_from_raw(i_unit, "current") - - if plotly_available and interactive: - fig = _cycle_info_plot_plotly( - cell, - cycle, - get_axes, - t_scaler, - t_unit, - v_scaler, - v_unit, - i_scaler, - i_unit, - **kwargs, - ) - if get_axes: - return fig - return fig - - axes = _cycle_info_plot_matplotlib( - cell, - cycle, - get_axes, - t_scaler, - t_unit, - v_scaler, - v_unit, - i_scaler, - i_unit, - **kwargs, - ) - - if get_axes: - return axes - - -def _cycle_info_plot_plotly( - cell, - cycle, - get_axes, - t_scaler, - t_unit, - v_scaler, - v_unit, - i_scaler, - i_unit, - **kwargs, -): - import plotly.express as px - import plotly.graph_objects as go - import numpy as np - - if kwargs.get("xlim"): - logging.info("xlim is not supported for plotly yet") - - # Per-cell, not the module-level legacy singletons (#567). - raw_hdr = _LiveHeaders(cell, "raw") - step_hdr = _LiveHeaders(cell, "steps") - - data = cell.data.raw.copy() - table = cell.data.steps.copy() - - if cycle is None: - cycle = list(data[raw_hdr.cycle_index_txt].unique()) - - if not isinstance(cycle, (list, tuple)): - cycle = [cycle] - - delta = "_delta" - v_delta = step_hdr.stat("voltage", "delta") - i_delta = step_hdr.stat("current", "delta") - c_delta = step_hdr.stat("charge", "delta") - dc_delta = step_hdr.stat("discharge", "delta") - cycle_ = step_hdr.cycle - step_ = step_hdr.step - type_ = step_hdr.type - - time_hdr = raw_hdr.test_time_txt - cycle_hdr = raw_hdr.cycle_index_txt - step_number_hdr = raw_hdr.step_index_txt - current_hdr = raw_hdr.current_txt - voltage_hdr = raw_hdr.voltage_txt - - data = data[ - [ - time_hdr, - cycle_hdr, - step_number_hdr, - current_hdr, - voltage_hdr, - ] - ] - - table = table[ - [ - cycle_, - step_, - type_, - v_delta, - i_delta, - c_delta, - dc_delta, - ] - ] - m_cycle_data = data[cycle_hdr].isin(cycle) - data = data.loc[m_cycle_data, :] - - data[time_hdr] = data[time_hdr] * t_scaler - data[voltage_hdr] = data[voltage_hdr] * v_scaler - data[current_hdr] = data[current_hdr] * i_scaler - data = data.merge( - table, - left_on=(cycle_hdr, step_number_hdr), - right_on=(cycle_, step_), - ).sort_values(by=[time_hdr]) - - fig = go.Figure() - - grouped_data = data.groupby(cycle_hdr) - for cycle_number, group in grouped_data: - x = group[time_hdr] - y = group[voltage_hdr] - s = group[step_number_hdr] - i = group[current_hdr] - - st = group[type_] - dV = group[v_delta] - dI = group[i_delta] - dC = group[c_delta] - dDC = group[dc_delta] - - fig.add_trace( - go.Scatter( - x=x, - y=y, - mode="lines", - name=f"cycle {cycle_number}", - customdata=np.stack((i, s, st, dV, dI, dC, dDC), axis=-1), - hovertemplate="
".join( - [ - "Time: %{x:.2f}" + f" {t_unit}" + "", - " Voltage: %{y:.4f}" + f" {v_unit}", - " Current: %{customdata[0]:.4f}" + f" {i_unit}", - "Step: %{customdata[1]} (%{customdata[2]})", - " ΔV: %{customdata[3]:.2f}", - " ΔI: %{customdata[4]:.2f}", - " ΔCh: %{customdata[5]:.2f}", - " ΔDCh: %{customdata[6]:.2f}", - ] - ), - ), + resolved_backend = backend + if interactive is not None: + warn_once( + "cycle_info_plot(interactive=...)", + 'backend="plotly"|"matplotlib"', + removal="2.1", ) + if resolved_backend is None: + resolved_backend = "plotly" if interactive else "matplotlib" + if resolved_backend is None: + resolved_backend = "plotly" - cell_name = kwargs.get("title", cell.cell_name) - height = kwargs.get("height", 600) - width = kwargs.get("width", 1000) - title_start = f"{cell_name} Cycle" - if len(cycle) > 2: - if cycle[-1] - cycle[0] == len(cycle) - 1: - title = f"{title_start}s {cycle[0]} - {cycle[-1]}" - else: - title = f"{title_start}s {cycle}" - elif len(cycle) == 2: - title = f"{title_start}s {cycle[0]} and {cycle[1]}" - else: - title = f"{title_start} {cycle[0]}" + if resolved_backend == "plotly" and not plotly_available: + warnings.warn("Can not perform interactive plotting. Plotly is not available.") + resolved_backend = "matplotlib" - fig.update_layout( - title=title, - xaxis_title=f"Time ({t_unit})", - yaxis_title=f"Voltage ({v_unit})", - width=width, - height=height, + from cellpy.plotting.backends import get_backend + from cellpy.plotting.context import from_source + from cellpy.plotting.prepare.steps import ( + CycleInfoPrepareConfig, + prepare as prepare_cycle_info, ) - if get_axes: - return fig - fig.show() - - -def _plot_step(ax, x, y, color): - ax.plot(x, y, color=color, linewidth=3) - - -def _get_info(table, cycle, step, step_hdr): - """``step_hdr`` is passed in: this helper never sees the cell, and the - module-level singleton it used to read answered with legacy names (#567).""" - m_table = (table[step_hdr.cycle] == cycle) & (table[step_hdr.step] == step) - # Step-table statistic columns, composed from the live stems rather than - # hard-coded legacy spellings ("voltage_delta" is "potential_delta" on a - # native frame). - p1, p2 = table.loc[ - m_table, [step_hdr.stat("point", "min"), step_hdr.stat("point", "max")] - ].values[0] - c1, c2 = ( - table.loc[ - m_table, - [step_hdr.stat("current", "min"), step_hdr.stat("current", "max")], - ] - .abs() - .values[0] + prep_config = CycleInfoPrepareConfig( + cycle=cycle, + get_axes=get_axes, + t_unit=t_unit, + v_unit=v_unit, + i_unit=i_unit, + backend=resolved_backend, + additional_kwargs=dict(kwargs), ) - d_voltage, d_current = table.loc[ - m_table, [step_hdr.stat("voltage", "delta"), step_hdr.stat("current", "delta")] - ].values[0] - d_discharge, d_charge = table.loc[ - m_table, - [step_hdr.stat("discharge", "delta"), step_hdr.stat("charge", "delta")], - ].values[0] - current_max = (c1 + c2) / 2 - rate = table.loc[m_table, step_hdr.rate_avr].values[0] - step_type = table.loc[m_table, step_hdr.type].values[0] - return [step_type, rate, current_max, d_voltage, d_current, d_discharge, d_charge] - - -def _cycle_info_plot_matplotlib( - cell, - cycle, - get_axes, - t_scaler, - t_unit, - v_scaler, - v_unit, - i_scaler, - i_unit, - **kwargs, -): - # obs! hard-coded col-names. Please fix me. - if cycle is None: - warnings.warn("Only one cycle at a time is supported for matplotlib") - cycle = 1 - - if isinstance(cycle, (list, tuple)): - warnings.warn("Only one cycle at a time is supported for matplotlib") - cycle = cycle[0] - - data = cell.data.raw - table = cell.data.steps - - # Per-cell, not the module-level legacy singletons (#567). - raw_hdr = _LiveHeaders(cell, "raw") - step_hdr = _LiveHeaders(cell, "steps") - - span_colors = ["#4682B4", "#FFA07A"] - - voltage_color = "#008B8B" - current_color = "#CD5C5C" - - m_cycle_data = data[raw_hdr.cycle_index_txt] == cycle - all_steps = data[m_cycle_data][raw_hdr.step_index_txt].unique() - - color = itertools.cycle(span_colors) - - fig = plt.figure(figsize=(20, 8)) - fig.suptitle(f"Cycle: {cycle}") - - ax3 = plt.subplot2grid((8, 3), (0, 0), colspan=3, rowspan=1, fig=fig) # steps - ax4 = plt.subplot2grid((8, 3), (1, 0), colspan=3, rowspan=2, fig=fig) # info - ax1 = plt.subplot2grid((8, 3), (3, 0), colspan=3, rowspan=5, fig=fig) # data - - ax2 = ax1.twinx() - ax1.set_xlabel(f"time ({t_unit})") - ax1.set_ylabel(f"voltage ({v_unit})", color=voltage_color) - ax2.set_ylabel(f"current ({i_unit})", color=current_color) - - annotations_1 = [] # step number (IR) - annotations_2 = [] # step number - annotations_4 = [] # info - - for i, s in enumerate(all_steps): - m = m_cycle_data & (data[raw_hdr.step_index_txt] == s) - c = data.loc[m, raw_hdr.current_txt] * i_scaler - v = data.loc[m, raw_hdr.voltage_txt] * v_scaler - t = data.loc[m, raw_hdr.test_time_txt] * t_scaler - step_type, rate, current_max, dv, dc, d_discharge, d_charge = _get_info( - table, cycle, s, step_hdr - ) - if len(t) > 1: - fcolor = next(color) - - info_txt = f"{step_type}\ni = |{i_scaler * current_max:0.2f}| {i_unit}\n" - info_txt += f"delta V = {dv:0.2f} %\ndelta i = {dc:0.2f} %\n" - info_txt += f"delta C = {d_charge:0.2} %\ndelta DC = {d_discharge:0.2} %\n" - - for ax in [ax2, ax3, ax4]: - ax.axvspan(t.iloc[0], t.iloc[-1], facecolor=fcolor, alpha=0.2) - _plot_step(ax1, t, v, voltage_color) - _plot_step(ax2, t, c, current_color) - annotations_1.append([f"{s}", t.mean()]) - annotations_4.append([info_txt, t.mean()]) - else: - info_txt = f"{s}({step_type})" - annotations_2.append([info_txt, t.mean()]) - ax3.set_ylim(0, 1) - for s in annotations_1: - ax3.annotate(f"{s[0]}", (s[1], 0.2), ha="center") - - for s in annotations_2: - ax3.annotate(f"{s[0]}", (s[1], 0.6), ha="center") - - for s in annotations_4: - ax4.annotate(f"{s[0]}", (s[1], 0.0), ha="center") - - for ax in [ax3, ax4]: - ax.axes.get_yaxis().set_visible(False) - ax.axes.get_xaxis().set_visible(False) + ctx = from_source(cell) + family = plot_registry.get("cycle_info") + frame, spec = prepare_cycle_info(ctx, family, prep_config) + fig = get_backend(resolved_backend).render(frame, spec) - if x := kwargs.get("xlim"): - ax1.set_xlim(x) - ax2.set_xlim(x) - ax3.set_xlim(x) - ax4.set_xlim(x) + if resolved_backend == "plotly": + if get_axes: + return fig + fig.show() + return None + # matplotlib: historical default returns None unless get_axes=True. if get_axes: - return ax1, ax2, ax2, ax4 + return fig + return None + @notebook_docstring_printer diff --git a/tests/figure_spec_support.py b/tests/figure_spec_support.py index 8d99b752..f26dd082 100644 --- a/tests/figure_spec_support.py +++ b/tests/figure_spec_support.py @@ -222,24 +222,24 @@ def _other_family_cases() -> list[FigureCase]: FigureCase( name="raw_plot[plotly]", function="raw_plot", - kwargs={"interactive": True}, + kwargs={"backend": "plotly"}, needs_plotly=True, ), FigureCase( name="raw_plot[matplotlib]", function="raw_plot", - kwargs={"interactive": False}, + kwargs={"backend": "matplotlib"}, ), FigureCase( name="cycle_info_plot[plotly]", function="cycle_info_plot", - kwargs={"cycle": 3, "interactive": True}, + kwargs={"cycle": 3, "backend": "plotly"}, needs_plotly=True, ), FigureCase( name="cycle_info_plot[matplotlib]", function="cycle_info_plot", - kwargs={"cycle": 3, "interactive": False}, + kwargs={"cycle": 3, "backend": "matplotlib"}, ), FigureCase( name="cycles_plot[plotly]", diff --git a/tests/test_raw_cycle_info_prepare.py b/tests/test_raw_cycle_info_prepare.py new file mode 100644 index 00000000..c17783e8 --- /dev/null +++ b/tests/test_raw_cycle_info_prepare.py @@ -0,0 +1,99 @@ +"""Tests for raw_plot / cycle_info_plot prepare → spec → render (#647).""" + +from __future__ import annotations + +import warnings + +import pytest + +from cellpy.plotting.context import from_source +from cellpy.plotting.prepare.raw import RawPrepareConfig, prepare as prepare_raw +from cellpy.plotting.prepare.steps import ( + CycleInfoPrepareConfig, + prepare as prepare_cycle_info, +) +from cellpy.plotting import registry as plot_registry +from cellpy.utils import plotutils +from cellpy.utils.plotutils import cycle_info_plot, raw_plot + + +@pytest.mark.essential +def test_private_raw_and_cycle_info_helpers_are_gone(): + assert not hasattr(plotutils, "_cycle_info_plot_plotly") + assert not hasattr(plotutils, "_cycle_info_plot_matplotlib") + assert not hasattr(plotutils, "_get_info") + assert not hasattr(plotutils, "_plot_step") + + +@pytest.mark.essential +def test_prepare_raw_returns_raw_spec(cell): + family = plot_registry.get("raw") + ctx = from_source(cell) + config = RawPrepareConfig(backend="matplotlib") + frame, spec = prepare_raw(ctx, family, config) + assert not frame.empty + assert spec.extras.get("kind") == "raw" + assert spec.extras.get("y") + assert "Time" in (spec.x_axis.label or "") + + +@pytest.mark.essential +def test_prepare_cycle_info_returns_cycle_info_spec(cell): + family = plot_registry.get("cycle_info") + ctx = from_source(cell) + config = CycleInfoPrepareConfig(cycle=3, backend="matplotlib") + frame, spec = prepare_cycle_info(ctx, family, config) + assert not frame.empty + assert spec.extras.get("kind") == "cycle_info" + assert spec.extras.get("cycle") == 3 + assert "steps" in spec.extras + + +@pytest.mark.essential +def test_raw_plot_backend_matplotlib(cell): + fig = raw_plot(cell, backend="matplotlib") + assert fig is not None + assert hasattr(fig, "get_axes") + + +@pytest.mark.essential +def test_cycle_info_plot_backend_matplotlib(cell): + assert cycle_info_plot(cell, cycle=3, backend="matplotlib") is None + axes = cycle_info_plot(cell, cycle=3, backend="matplotlib", get_axes=True) + assert axes is not None + + +@pytest.mark.essential +def test_raw_plot_interactive_alias_warns(cell): + from cellpy import _deprecation + + _deprecation._WARNED_SITES.clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + fig = raw_plot(cell, interactive=False) + assert fig is not None + messages = [ + str(w.message) + for w in caught + if issubclass(w.category, DeprecationWarning) + and "interactive" in str(w.message) + ] + assert messages + assert "backend=" in messages[0] or "matplotlib" in messages[0] + + +@pytest.mark.essential +def test_cycle_info_plot_interactive_alias_warns(cell): + from cellpy import _deprecation + + _deprecation._WARNED_SITES.clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + cycle_info_plot(cell, cycle=3, interactive=False) + messages = [ + str(w.message) + for w in caught + if issubclass(w.category, DeprecationWarning) + and "interactive" in str(w.message) + ] + assert messages