diff --git a/.issueflows/03-solved-issues/issue801_original.md b/.issueflows/03-solved-issues/issue801_original.md new file mode 100644 index 00000000..0684a737 --- /dev/null +++ b/.issueflows/03-solved-issues/issue801_original.md @@ -0,0 +1,9 @@ +# Issue #801: App-friendly collected figures: theme / label / height hook (or pass a FigureSpec) + +Source: https://github.com/jepegit/cellpy/issues/801 + +## Original issue text + +Split from #791 (item c). `collected_plot` returns faceted figures with default plotly styling (mirror axis boxes, right-side facet titles spelled `variable=charge_capacity_gravimetric`, auto height growing with facet count). A **theme / label / height hook** (or a `FigureSpec` the caller can pass) would let apps drop the figure in without re-styling every one. + +Relates to the plotting `FigureSpec` pipeline and SPEED-30 label work. diff --git a/.issueflows/03-solved-issues/issue801_plan.md b/.issueflows/03-solved-issues/issue801_plan.md new file mode 100644 index 00000000..01f42f35 --- /dev/null +++ b/.issueflows/03-solved-issues/issue801_plan.md @@ -0,0 +1,98 @@ +# Issue #801 — Plan: App-friendly collected figures (theme / label / height) + +Status: **confirmed** (2026-07-31) — open questions resolved as recommended: +pretty labels default; no `spec=` this PR; include `layout_updates`. + +## Goal + +Give apps first-class, documented knobs on `collected_plot` / `Collection.plot` so faceted Plotly figures can be dropped into an app shell without a private restyle pass — covering **template/theme**, **facet labels**, and **height**. + +## Constraints + +- Patch release (`v.2.1.2`); stay on the **collected** Plotly path (`Collection.plot` → `collected_plot` → `summary_plotter` / `_cycles_plotter`). Do not rework single-cell prepare→render. +- Collected path still builds a thin `FigureSpec` (extras only; panels unused) — same as #804. Do **not** force a full PanelSpec migration. +- Prefer additive kwargs + clearer defaults over a new theming framework. +- Companion context: [cellpy/cellpy-simple-gui](https://github.com/cellpy/cellpy-simple-gui) pain-point §11 (restyles every figure today); #804 already landed `share_y` / `y_ranges`. +- Design docs: [plotting-collected.md](../04-designs-and-guides/plotting-collected.md), [plotting-backends.md](../04-designs-and-guides/plotting-backends.md). + +### Prior art + +| Hit | Where | Relation | +| --- | --- | --- | +| Hardcoded `template = f"{PLOTLY_BASE_TEMPLATE}+{method}"` | [`collected.py`](../../cellpy/plotting/collected.py) `_cycles_plotter` | Theme seam — no public override today | +| `make_collector_templates` / `make_plotly_template` | [`theme.py`](../../cellpy/plotting/theme.py) | Axis chrome only; apps still restyle paper/legend/facet strip | +| `y_label_mapper` + `_plotly_y_label_cleaner` | `sequence_plotter` / `summary_plotter` | Label seam exists; only populated when `units=` is passed (Batch path). Plain `Collection.plot` keeps `variable=…` facet titles | +| `height` / `sub_fig_min_height` / `figure_border_height` | `_cycles_plotter` | Height seam exists but names are obscure; summary forces `sub_fig_min_height=300` | +| `plotly_template` on single-cell prepare configs | `prepare/{summary,curves,ica}.py` | Naming to mirror for collected override | +| Thin `FigureSpec(extras=…)` in `collected_plot` | [`collected.py`](../../cellpy/plotting/collected.py) | Escape hatch possible via merging `render_opts`; panels still ignored | +| Toolbox / graphify | `.issueflows/00-tools/`, `graphify-out/` | No plotting-theme helper | + +## Approach + +Ship **three public knobs** (kwargs through `Collection.plot` / `collected_plot` → `render_opts`), document them, and improve the default label path so apps are not forced to pass `units=` just to drop `variable=`. + +### 1. Theme / template + +- Accept `plotly_template=` (string; Plotly template name or `"+"`-combined). When set, use it in `_cycles_plotter`'s `fig.update_layout(template=…)` instead of hardcoding `simple_white+{method}`. +- Accept optional `layout_updates: Mapping[str, Any]` applied after the collector layout (paper/plot bgcolor, margin, legend dict patches). Keep this a shallow `update_layout(**layout_updates)` — not a second theme system. +- Out of scope: new light/dark token packs, discrete colorway registry (existing `palette=` / `palette_*` stay as-is). + +### 2. Labels (facet strip / y titles) + +- When `y_label_mapper` is omitted, build a **default pretty mapper** from the frame's `variable` values (title-case / split `_`, optional unit suffix only if `units=` present — reuse the existing units branch). Always strip Plotly's `variable=` annotation text (move facet label onto the y-axis title and clear the side strip), matching today's `y_label_mapper` behaviour. +- Keep explicit `y_label_mapper=` as override (wins over the default). +- Gate the new default behind `pretty_labels=True` **if** Open question 1 chooses opt-in; otherwise make it the summary default (recommended). + +### 3. Height + +- Document and prefer public names: + - `height=` — absolute figure height (already works). + - `height_per_panel=` — alias of `sub_fig_min_height` (clearer for apps). + - keep `figure_border_height=` / `sub_fig_min_height=` as synonyms. +- Ensure summary's internal default (`300` per panel) still applies when neither absolute `height` nor `height_per_panel` is given. +- No change to the experimental `height_fractions` path. + +### 4. Optional `spec=` (thin) + +- Allow `collected_plot(..., spec=FigureSpec)` / `spec=` on `Collection.plot`: merge `spec.extras["render_opts"]` into opts, honour `spec.title`, and if `spec.extras` carries `plotly_template` / `layout_updates`, treat them like the kwargs above. Still ignore `panels` (same as #804). +- If Open question 2 says kwargs-only, skip this bullet. + +### 5. Docs + +- Update `plotting-collected.md` with the three knobs + a short app example. +- Docstrings on `collected_plot` / `Collection.plot` / `summary_plotter`. +- Tiny example in existing plotting/collect docs if a natural home exists (no new guide). + +## Files to touch + +| Path | Change | +| --- | --- | +| [`cellpy/plotting/collected.py`](../../cellpy/plotting/collected.py) | `plotly_template`, `layout_updates`, default/`pretty_labels` mapper, `height_per_panel` alias; optional `spec=` merge; docstrings | +| [`cellpy/collect/collection.py`](../../cellpy/collect/collection.py) | Docstring mention of the new knobs | +| [`.issueflows/04-designs-and-guides/plotting-collected.md`](../04-designs-and-guides/plotting-collected.md) | Decision bullets + example | +| `tests/test_collected_summary_axes.py` or new `tests/test_collected_app_hooks.py` | Assert template override, pretty labels (no `variable=`), height math | + +## Test strategy + +```bash +uv run pytest tests/test_collected_summary_axes.py tests/test_collected_app_hooks.py -q +MPLBACKEND=Agg uv run pytest -m essential +``` + +- Synthetic long summary frame (reuse #804 helper pattern). +- Assert: with `plotly_template="plotly_white"`, layout template reflects it; with default/pretty labels, annotations empty or lack `variable=`; with `height_per_panel=180` and 2 variables, `layout.height == figure_border_height + 2*180` (or documented formula). +- Mark essential only if cheap (same bar as #804). + +## Open questions + +1. **Pretty labels default?** + - **Recommended: yes** — summary collected path always builds a pretty mapper when `y_label_mapper` is omitted (visual change: side `variable=…` strip goes away; y-axis titles become humanized). + - Alternative: opt-in `pretty_labels=True`. + +2. **`spec=` escape hatch this PR?** + - **Recommended: no** — kwargs cover the issue text; thin `FigureSpec` merge can wait. + - Alternative: accept `spec=` merge as above. + +3. **`layout_updates=` this PR?** + - **Recommended: yes** (small; unblocks paper/plot colors without a template factory). + - Alternative: template string only. diff --git a/.issueflows/03-solved-issues/issue801_status.md b/.issueflows/03-solved-issues/issue801_status.md new file mode 100644 index 00000000..e772340a --- /dev/null +++ b/.issueflows/03-solved-issues/issue801_status.md @@ -0,0 +1,21 @@ +# Issue #801 — Status + +- [x] Done + +## What's done + +- Plan accepted (pretty labels default; no `spec=`; include `layout_updates`). +- Implemented on collected Plotly path: + - `plotly_template=` override + - `layout_updates=` after collector layout + - default pretty `y_label_mapper` (clears `variable=…` facet strip) + - `height_per_panel=` alias of `sub_fig_min_height` + - `y_ranges` applied before label cleanup (keeps #804 working with pretty labels) +- Docs: `plotting-collected.md`, docstrings on `collected_plot` / `Collection.plot` / `summary_plotter`. +- Tests: `tests/test_collected_app_hooks.py` + updated `tests/test_collected_summary_axes.py`. +- `HISTORY.md` Unreleased bullet added. +- Essential suite green (`635 passed`); PR https://github.com/jepegit/cellpy/pull/808 + +## Remaining work + +- None — ready for merge / `/iflow-cleanup` after merge. diff --git a/.issueflows/04-designs-and-guides/plotting-collected.md b/.issueflows/04-designs-and-guides/plotting-collected.md index 2f7f2a69..d686284d 100644 --- a/.issueflows/04-designs-and-guides/plotting-collected.md +++ b/.issueflows/04-designs-and-guides/plotting-collected.md @@ -34,6 +34,13 @@ Epic #567 Stage 3 / issue #657 re-bases collectors' drawing half onto `y_ranges={"coulombic_efficiency": [0, 110], ...}` (variable → `[lo, hi]`). Non-empty `y_ranges` forces independent axes. Plotly is the supported backend for `y_ranges`; seaborn/matplotlib are best-effort and ignore it. +- **App chrome (#801):** `plotly_template=` overrides the default + `plotly+{method}` combo; `layout_updates=` is a shallow + `fig.update_layout(**…)` after collector styling. Summary facet strips no + longer keep raw `variable=…` text by default — pretty y-axis titles are + built automatically (`y_label_mapper=` overrides). Height: + `height=` (absolute) or `height_per_panel=` (alias of `sub_fig_min_height`; + summary default 300 px/panel) plus optional `figure_border_height=`. ### Example — Capacity + CE without crushing panels @@ -45,9 +52,22 @@ collection.plot( ) ``` +### Example — drop into an app shell + +```python +collection.plot( + family_kind="summary", + plotly_template="plotly_white", + layout_updates={"paper_bgcolor": "#f7f7f7", "margin": dict(l=60, r=20, t=40, b=40)}, + height_per_panel=220, + y_ranges={"coulombic_efficiency": [0, 110]}, +) +``` + ## Links - Issue #657; epic #567; plan `architecture-plan/cellpy2-plotting-redesign-plan.md` §3.3 / Phase 4 - Related: `plotting-prepare.md`, `plotting-backends.md` - Issue #804 (per-panel y-limits / `share_y`) +- Issue #801 (theme / label / height hooks) diff --git a/HISTORY.md b/HISTORY.md index 9e696144..ed7a912b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,9 @@ ## [Unreleased] +* App-friendly collected figures: theme / label / height hook (or pass a + FigureSpec). (#801) + ## [2.1.1.post3] - 2026-07-30 Post-release of 2.1.1 — silence loader-discovery WARNING spam for apps. diff --git a/cellpy/collect/collection.py b/cellpy/collect/collection.py index f7289fcd..6cfb7117 100644 --- a/cellpy/collect/collection.py +++ b/cellpy/collect/collection.py @@ -83,7 +83,9 @@ def plot(self, *, family_kind: str | None = None, **kwargs): For summary collections (Plotly), pass ``share_y`` / ``match_axes`` and optional ``y_ranges={variable: [lo, hi], ...}`` for per-facet y-limits - (see :func:`cellpy.plotting.collected.summary_plotter`). + (see :func:`cellpy.plotting.collected.summary_plotter`). App chrome: + ``plotly_template``, ``layout_updates``, ``y_label_mapper``, + ``height`` / ``height_per_panel``. """ from cellpy.plotting import collected_plot diff --git a/cellpy/plotting/collected.py b/cellpy/plotting/collected.py index 21b6e956..109eebdc 100644 --- a/cellpy/plotting/collected.py +++ b/cellpy/plotting/collected.py @@ -540,6 +540,9 @@ def sequence_plotter( elif method == "summary": logging.info("sequence-plotter - summary plotly") + # Must not reach px.line / spread_plot (unknown kwarg). + y_ranges = kwargs.pop("y_ranges", None) or {} + 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( @@ -630,6 +633,11 @@ def sequence_plotter( except Exception as e: print(f"sequence_plotter - summary - failed {e} [{group}]") + # Apply per-panel y-limits while facet annotations still spell + # ``variable=…`` (#804 / #801). Pretty-label cleanup clears them. + if y_ranges and not spread: + _apply_summary_y_ranges(fig, y_ranges, facet=g) + if y_label_mapper and not spread: y_label_mapper = _plotly_y_label_cleaner(y_label_mapper) annotations = fig.layout.annotations @@ -867,6 +875,61 @@ def _resolve_share_y( return bool(default) +def _pretty_variable_label(variable: str, units: Any = None) -> str: + """Humanize a summary ``variable`` column name for facet / y-axis titles. + + Strips specific-mode suffixes (``_gravimetric`` / ``_areal`` / …). When + ``units`` is the Batch-style dict used by :func:`summary_plotter`, appends + a parenthetical unit; otherwise returns the title-cased name alone. + """ + v = str(variable) + u_sub = None + if units: + cellpy_units = units["cellpy_units"] + if v.endswith("_areal") or v.endswith("_areal_cv"): + u_sub = cellpy_units.specific_areal + elif v.endswith("_gravimetric") or v.endswith("_gravimetric_cv"): + u_sub = cellpy_units.specific_gravimetric + elif v.endswith("_volumetric") or v.endswith("_volumetric_cv"): + u_sub = cellpy_units.specific_volumetric + + u_top = None + if units: + cellpy_units = units["cellpy_units"] + if "_capacity" in v: + u_top = cellpy_units.charge + if "_norm" in v: + u_top = "normalized" + if v == "coulombic_efficiency": + u_top = "%" + + parts = v.split("_") + mode_suffixes = {"gravimetric", "areal", "volumetric"} + if parts and parts[-1] == "cv" and len(parts) >= 2 and parts[-2] in mode_suffixes: + parts = parts[:-2] + ["cv"] + elif parts and parts[-1] in mode_suffixes: + parts = parts[:-1] + label = " ".join(parts).title() + if label.endswith("Cv"): + label = label.replace("Cv", "CV") + + if not units: + return label + + u = u_top or "Value" + if u_sub: + u_sub = str(u_sub).replace("**", "") + u = f"{u}/{u_sub}" + return f"{label} ({u})" + + +def _default_summary_y_label_mapper( + variables: list[str], units: Any = None +) -> dict[str, str]: + """Build ``variable → pretty label`` for collected summary facets (#801).""" + return {v: _pretty_variable_label(v, units=units) for v in variables} + + def _yaxis_key_for_facet_label(fig: Any, label: str) -> Optional[str]: """Map a Plotly facet annotation text to its ``yaxis`` / ``yaxisN`` key.""" annotations = getattr(fig.layout, "annotations", None) or () @@ -897,6 +960,32 @@ def _yaxis_key_for_facet_label(fig: Any, label: str) -> Optional[str]: return best_key +def _yaxis_key_for_variable( + fig: Any, variable: str, *, facet: str = "variable" +) -> Optional[str]: + """Resolve a summary facet row's y-axis key by annotation or axis title. + + Prefer the Plotly ``variable=…`` facet strip (present before pretty-label + cleanup). After labels move onto y-axis titles, match the pretty title. + """ + key = _yaxis_key_for_facet_label(fig, f"{facet}={variable}") + if key is not None: + return key + pretty = _pretty_variable_label(variable) + for layout_key in fig.layout: + key_s = str(layout_key) + if not key_s.startswith("yaxis"): + continue + title = getattr(fig.layout[layout_key].title, "text", None) + if not title: + continue + if title == pretty or title.startswith(f"{pretty} ") or title.startswith( + f"{pretty} (" + ): + return key_s + return None + + _warned_unknown_y_range_keys: set[str] = set() @@ -926,7 +1015,7 @@ def _apply_summary_y_ranges( stacklevel=3, ) continue - axis_key = _yaxis_key_for_facet_label(fig, f"{facet}={variable}") + axis_key = _yaxis_key_for_variable(fig, variable, facet=facet) if axis_key is None: if variable not in _warned_unknown_y_range_keys: _warned_unknown_y_range_keys.add(variable) @@ -982,8 +1071,16 @@ def _cycles_plotter( legend_title = kwargs.pop("legend_title", None) show_legend = kwargs.pop("show_legend", None) cols = kwargs.pop("cols", 3) + height_per_panel = kwargs.pop("height_per_panel", None) + sub_fig_min_height_explicit = "sub_fig_min_height" in kwargs sub_fig_min_height = kwargs.pop("sub_fig_min_height", 200) + if height_per_panel is not None: + sub_fig_min_height = height_per_panel figure_border_height = kwargs.pop("figure_border_height", 100) + plotly_template = kwargs.pop("plotly_template", None) + layout_updates = kwargs.pop("layout_updates", None) or {} + if layout_updates and not isinstance(layout_updates, dict): + raise TypeError("layout_updates must be a dict of Plotly layout kwargs") # kwargs from default `BatchCollector.render` method not used by `sequence_plotter`: journal = kwargs.pop("journal", None) units = kwargs.pop("units", None) @@ -1012,7 +1109,10 @@ def _cycles_plotter( number_of_figs = len(collected_curves[z].unique()) elif method == "summary": number_of_figs = len(collected_curves["variable"].unique()) - sub_fig_min_height = 300 + # Default 300 px/panel unless the caller set height_per_panel or + # sub_fig_min_height explicitly (#801). + if height_per_panel is None and not sub_fig_min_height_explicit: + sub_fig_min_height = 300 else: number_of_figs = 1 @@ -1042,7 +1142,11 @@ def _cycles_plotter( # Rendering: if backend == "plotly": - template = f"{PLOTLY_BASE_TEMPLATE}+{method}" + template = ( + plotly_template + if plotly_template is not None + else f"{PLOTLY_BASE_TEMPLATE}+{method}" + ) legend_orientation = "v" if legend_position == "bottom": @@ -1064,6 +1168,8 @@ def _cycles_plotter( height=height, width=width, ) + if layout_updates: + fig.update_layout(**layout_updates) if not match_axes: fig.update_yaxes(matches=None) fig.update_xaxes(matches=None) @@ -1086,6 +1192,15 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k - ``y_ranges``: mapping of ``variable`` name → ``[lo, hi]`` for per-panel fixed limits. Omitted variables keep autorange. A non-empty ``y_ranges`` forces independent axes. Supported for ``backend="plotly"`` only. + + App-facing chrome (Plotly, #801): + + - ``plotly_template``: override the default ``plotly+summary`` template. + - ``layout_updates``: dict passed to ``fig.update_layout`` after collector styling. + - ``y_label_mapper``: ``variable → label``; when omitted, pretty labels are + built automatically (facet ``variable=…`` strip cleared). + - ``height`` / ``height_per_panel`` (alias of ``sub_fig_min_height``) / + ``figure_border_height``: absolute or per-panel height control. """ # start_cell is used to determine the starting cell for the subplots (plotly) @@ -1177,9 +1292,7 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k group_cells = kwargs.pop("group_cells", True) units = kwargs.pop("units", None) - label_mapper = { - f"{y}": None, - } + explicit_y_label_mapper = kwargs.pop("y_label_mapper", None) # order the variables by a given order: order_variables = kwargs.pop("order_variables", None) if order_variables: @@ -1188,45 +1301,12 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k ) 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})" + variables = list(collected_curves[g].unique()) + if explicit_y_label_mapper is not None: + y_label_mapper = explicit_y_label_mapper + else: + # Default pretty labels so apps are not stuck with ``variable=…`` (#801). + y_label_mapper = _default_summary_y_label_mapper(variables, units=units) # 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) @@ -1243,7 +1323,7 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k standard_deviation=standard_deviation, x_label=x_label, x_unit=x_unit, - y_label_mapper=label_mapper[y], + y_label_mapper=y_label_mapper, group_cells=group_cells, default_title="Summary Plot", backend=backend, @@ -1251,6 +1331,7 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k cycles=cycles_to_plot, cols=cols, match_axes=share_y_resolved, + y_ranges=y_ranges, **kwargs, ) @@ -1565,6 +1646,9 @@ def collected_plot( For ``family_kind="summary"`` (Plotly): ``share_y`` / ``match_axes`` control shared vs independent facet y-scales (default independent); ``y_ranges`` maps variable name → ``[lo, hi]`` for per-panel limits. + App chrome (#801): ``plotly_template``, ``layout_updates``, + ``y_label_mapper`` (pretty labels by default), ``height`` / + ``height_per_panel`` / ``figure_border_height``. Returns: Backend-native figure object. diff --git a/tests/test_collected_app_hooks.py b/tests/test_collected_app_hooks.py new file mode 100644 index 00000000..88d4c448 --- /dev/null +++ b/tests/test_collected_app_hooks.py @@ -0,0 +1,165 @@ +"""Collected summary app chrome: template / labels / height (#801).""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from cellpy.plotting.collected import ( + _default_summary_y_label_mapper, + _pretty_variable_label, +) + + +def _summary_frame() -> pd.DataFrame: + rows = [] + for cell in ("a", "b"): + for cycle in (1, 2, 3): + rows.append( + { + "cycle": cycle, + "cell": cell, + "group": 1, + "sub_group": 1, + "variable": "charge_capacity_gravimetric", + "value": 100.0 + cycle, + } + ) + rows.append( + { + "cycle": cycle, + "cell": cell, + "group": 1, + "sub_group": 1, + "variable": "coulombic_efficiency", + "value": 98.0, + } + ) + return pd.DataFrame(rows) + + +@pytest.mark.essential +def test_pretty_variable_label_strips_mode_suffix(): + assert _pretty_variable_label("charge_capacity_gravimetric") == "Charge Capacity" + assert _pretty_variable_label("coulombic_efficiency") == "Coulombic Efficiency" + assert _pretty_variable_label("discharge_capacity_areal_cv") == "Discharge Capacity CV" + + +@pytest.mark.essential +def test_default_summary_y_label_mapper(): + mapper = _default_summary_y_label_mapper( + ["charge_capacity_gravimetric", "coulombic_efficiency"] + ) + assert mapper["charge_capacity_gravimetric"] == "Charge Capacity" + assert mapper["coulombic_efficiency"] == "Coulombic Efficiency" + + +@pytest.mark.essential +def test_summary_pretty_labels_clear_variable_facet_strip(): + pytest.importorskip("plotly", reason="plotting extras (batch) not installed") + from cellpy.plotting import theme + from cellpy.plotting.collected import summary_plotter + + theme.make_collector_templates() + fig = summary_plotter(_summary_frame(), backend="plotly", group_cells=False) + assert fig is not None + texts = [getattr(a, "text", None) or "" for a in (fig.layout.annotations or ())] + assert not any(t.startswith("variable=") for t in texts) + y_titles = [ + fig.layout[k].title.text + for k in fig.layout + if str(k).startswith("yaxis") and fig.layout[k].title.text + ] + assert "Charge Capacity" in y_titles + assert "Coulombic Efficiency" in y_titles + + +@pytest.mark.essential +def test_summary_explicit_y_label_mapper_wins(): + pytest.importorskip("plotly", reason="plotting extras (batch) not installed") + from cellpy.plotting import theme + from cellpy.plotting.collected import summary_plotter + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + y_label_mapper={ + "charge_capacity_gravimetric": "Cap", + "coulombic_efficiency": "CE", + }, + ) + y_titles = [ + fig.layout[k].title.text + for k in fig.layout + if str(k).startswith("yaxis") and fig.layout[k].title.text + ] + assert "Cap" in y_titles + assert "CE" in y_titles + + +@pytest.mark.essential +def test_summary_plotly_template_and_layout_updates(): + pytest.importorskip("plotly", reason="plotting extras (batch) not installed") + from cellpy.plotting import theme + from cellpy.plotting.collected import summary_plotter + + theme.make_collector_templates() + fig_default = summary_plotter( + _summary_frame(), backend="plotly", group_cells=False + ) + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + plotly_template="plotly_white", + layout_updates={"paper_bgcolor": "rgb(1,2,3)", "plot_bgcolor": "rgb(4,5,6)"}, + ) + assert fig is not None + # Plotly expands the named template into a Template object (name not kept). + assert str(fig.layout.template) != str(fig_default.layout.template) + assert fig.layout.paper_bgcolor == "rgb(1,2,3)" + assert fig.layout.plot_bgcolor == "rgb(4,5,6)" + + +@pytest.mark.essential +def test_summary_height_per_panel(): + pytest.importorskip("plotly", reason="plotting extras (batch) not installed") + from cellpy.plotting import theme + from cellpy.plotting.collected import summary_plotter + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + height_per_panel=180, + figure_border_height=40, + cols=1, + ) + # 2 variables → 2 rows; height = border + rows * per_panel + assert fig.layout.height == 40 + 2 * 180 + + +@pytest.mark.essential +def test_collected_plot_forwards_app_hooks(): + pytest.importorskip("plotly", reason="plotting extras (batch) not installed") + from cellpy.plotting import theme + from cellpy.plotting.collected import collected_plot + + theme.make_collector_templates() + fig = collected_plot( + _summary_frame(), + family_kind="summary", + backend="plotly", + group_cells=False, + plotly_template="plotly_white", + height_per_panel=150, + figure_border_height=50, + cols=1, + layout_updates={"margin": dict(l=10, r=10, t=10, b=10)}, + ) + assert fig is not None + assert fig.layout.height == 50 + 2 * 150 + assert fig.layout.margin.l == 10 diff --git a/tests/test_collected_summary_axes.py b/tests/test_collected_summary_axes.py index 4cd4f9d2..5e24518d 100644 --- a/tests/test_collected_summary_axes.py +++ b/tests/test_collected_summary_axes.py @@ -95,7 +95,7 @@ def test_summary_y_ranges_per_panel(): pytest.importorskip("plotly", reason="plotting extras (batch) not installed") from cellpy.plotting import theme from cellpy.plotting.collected import ( - _yaxis_key_for_facet_label, + _yaxis_key_for_variable, summary_plotter, ) @@ -107,8 +107,8 @@ def test_summary_y_ranges_per_panel(): y_ranges={"coulombic_efficiency": [0, 110]}, ) assert fig is not None - ce_key = _yaxis_key_for_facet_label(fig, "variable=coulombic_efficiency") - cap_key = _yaxis_key_for_facet_label(fig, "variable=charge_capacity_gravimetric") + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + cap_key = _yaxis_key_for_variable(fig, "charge_capacity_gravimetric") assert ce_key is not None assert cap_key is not None assert list(fig.layout[ce_key].range) == [0.0, 110.0] @@ -122,7 +122,7 @@ def test_summary_y_ranges_forces_independent_when_share_y_true(): pytest.importorskip("plotly", reason="plotting extras (batch) not installed") from cellpy.plotting import theme from cellpy.plotting.collected import ( - _yaxis_key_for_facet_label, + _yaxis_key_for_variable, summary_plotter, ) @@ -136,7 +136,7 @@ def test_summary_y_ranges_forces_independent_when_share_y_true(): ) assert fig is not None assert fig.layout.yaxis2.matches in (None, False) - ce_key = _yaxis_key_for_facet_label(fig, "variable=coulombic_efficiency") + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") assert list(fig.layout[ce_key].range) == [0.0, 110.0] @@ -145,7 +145,7 @@ def test_collected_plot_forwards_y_ranges(): pytest.importorskip("plotly", reason="plotting extras (batch) not installed") from cellpy.plotting import theme from cellpy.plotting.collected import ( - _yaxis_key_for_facet_label, + _yaxis_key_for_variable, collected_plot, ) @@ -158,5 +158,5 @@ def test_collected_plot_forwards_y_ranges(): y_ranges={"coulombic_efficiency": [0.0, 110.0]}, ) assert fig is not None - ce_key = _yaxis_key_for_facet_label(fig, "variable=coulombic_efficiency") + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") assert list(fig.layout[ce_key].range) == [0.0, 110.0]