From 824d0aec53c7faca933c45693d0be3b00e2cac42 Mon Sep 17 00:00:00 2001 From: jepegit Date: Sat, 3 Oct 2026 21:32:58 +0200 Subject: [PATCH 1/2] fix refresh and x-axis --- src/cellpy/plotting/collected.py | 10 +- src/cellpy/readers/cellreader.py | 115 ++++- tests/test_collected_summary_axes.py | 602 ++++++++++++++------------- tests/test_refresh_after_meta.py | 135 +++--- 4 files changed, 495 insertions(+), 367 deletions(-) diff --git a/src/cellpy/plotting/collected.py b/src/cellpy/plotting/collected.py index 8ead7b66..505e3928 100644 --- a/src/cellpy/plotting/collected.py +++ b/src/cellpy/plotting/collected.py @@ -1556,11 +1556,16 @@ def _cycles_plotter( fig.update_layout(**layout_updates) # Affirmative link when sharing: px.line facets usually already set # matches, but spread_plot (make_subplots) never does (#817 / #804). + # Summary rows are all "value vs cycle", so the x-axis stays locked + # even when each row keeps its own y-scale. if match_axes: fig.update_yaxes(matches="y") else: fig.update_yaxes(matches=None) - fig.update_xaxes(matches=None) + if method != "summary": + fig.update_xaxes(matches=None) + if method == "summary": + fig.update_xaxes(matches="x") return fig @@ -1576,7 +1581,8 @@ def summary_plotter(collected_curves, cycles_to_plot=None, backend="plotly", **k - ``share_y`` (preferred) or ``match_axes``: when True, facet rows share one y-scale; when False (the default for summary), each row auto-scales. - If both are given, ``share_y`` wins. + If both are given, ``share_y`` wins. The x-axis (cycle number) stays + locked across summary rows either way. - ``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. diff --git a/src/cellpy/readers/cellreader.py b/src/cellpy/readers/cellreader.py index 7323230e..db8fee1c 100644 --- a/src/cellpy/readers/cellreader.py +++ b/src/cellpy/readers/cellreader.py @@ -73,19 +73,25 @@ # Meta fields that drive scaled / equivalent-cycle summary columns (not the # base cycle-end summary from ``make_core_summary``). Used by apps after # post-load edits (mass / area / nom-cap / cycle mode) — see ``refresh_after``. -# C-rate columns come from the step table and are independent of nom_cap. +# C-rates are current / absolute nominal capacity, so mass, area, and +# nominal_capacity refreshes recompute them from the step table. +_C_RATE_REFRESH_FIELDS = frozenset( + {"mass", "active_electrode_area", "nominal_capacity"} +) SUMMARY_META_DEPENDENCIES = { "mass": { - "affects": ("*_gravimetric",), + "affects": ("*_gravimetric", "charge_c_rate", "discharge_c_rate"), "notes": ( "Gravimetric specific columns are absolute × (mass conversion factor). " + "When nominal capacity is gravimetric, mass also rescales C-rates. " "Call refresh_after(('mass',)) after changing mass." ), }, "active_electrode_area": { - "affects": ("*_areal",), + "affects": ("*_areal", "charge_c_rate", "discharge_c_rate"), "notes": ( "Areal specific columns are absolute × (area conversion factor). " + "When nominal capacity is areal, area also rescales C-rates. " "Call refresh_after(('active_electrode_area',)) after changing area." ), }, @@ -94,11 +100,13 @@ "normalized_cycle_index", "equivalent_full_cycles", "test_cumulated_capacity_throughput", + "charge_c_rate", + "discharge_c_rate", ), "notes": ( - "Absolute nominal capacity rescales equivalent-cycle / EFC columns. " - "charge_c_rate / discharge_c_rate come from the step table and are " - "not derived from nominal_capacity." + "Absolute nominal capacity rescales equivalent-cycle / EFC columns " + "and recomputes charge_c_rate / discharge_c_rate from step current. " + "Call refresh_after(('nominal_capacity',)) after changing it." ), }, "cycle_mode": { @@ -4448,8 +4456,10 @@ def refresh_after(self, fields=None, **kwargs): area, nominal capacity, or cycle mode changed and a summary already exists. Re-runs the scaled / equivalent-cycle half of the summary pipeline (``core.add_scaled_summary_columns``) without rebuilding the - base cycle-end table. Falls back to ``make_summary`` when no summary - is present. + base cycle-end table. A mass, area, or nominal-capacity change also + recomputes step and summary C-rates from step current and the new + absolute nominal capacity. Falls back to ``make_summary`` when no + summary is present. See ``SUMMARY_META_DEPENDENCIES`` for the meta → column map apps can use for messaging / UI scope. @@ -4475,7 +4485,7 @@ def refresh_after(self, fields=None, **kwargs): c.refresh_after("mass") ``` """ - normalize_summary_meta_fields(fields) # validate early + normalized = normalize_summary_meta_fields(fields) try: data = self.data except NoDataFound: @@ -4491,6 +4501,8 @@ def refresh_after(self, fields=None, **kwargs): nom_cap=kwargs.get("nom_cap"), nom_cap_specifics=kwargs.get("nom_cap_specifics"), ) + if _C_RATE_REFRESH_FIELDS.intersection(normalized): + self._refresh_c_rates() return self def _resolve_nom_cap_abs(self, data, nom_cap=None, nom_cap_specifics=None): @@ -4543,6 +4555,91 @@ def _refresh_scaled_summary_columns( self.data = data return data + def _refresh_c_rates(self): + """Recompute step and summary C-rates from the current absolute capacity. + + Matches ``make_summary``: step ``c_rate`` is + ``abs(round(current_mean / nom_cap_abs, DIGITS_C_RATE))``, and the + summary charge / discharge columns take the first step of that type + in each cycle, scaled by the raw-current → cellpy-current factor. + The summary copy does not divide by nominal capacity again. + """ + data = self.data + steps = getattr(data, "steps", None) + summary = getattr(data, "summary", None) + if steps is None or summary is None: + return data + if getattr(steps, "empty", True) or getattr(summary, "empty", True): + return data + + sh = self.schema.steps + ch = self.schema.summary + try: + current_col = sh.current_mean + rate_col = sh.c_rate + type_col = sh.step_type + step_cycle = sh.cycle_num + except AttributeError: + logging.debug("refresh_after: step schema has no C-rate columns") + return data + if current_col not in steps.columns or type_col not in steps.columns: + logging.debug( + "refresh_after: steps missing %s or %s; skipping C-rate refresh", + current_col, + type_col, + ) + return data + + nom_cap_abs = self._resolve_nom_cap_abs(data) + if nom_cap_abs in (None, 0) or not np.isfinite(nom_cap_abs): + logging.warning( + "refresh_after: absolute nominal capacity is %r; skipping C-rate refresh", + nom_cap_abs, + ) + return data + + steps[rate_col] = (steps[current_col] / nom_cap_abs).round(DIGITS_C_RATE).abs() + + try: + raw_current = data.raw_units["current"] + except (KeyError, TypeError): + raw_current = None + if raw_current: + factor = core_units.calculate_current_conversion_factor( + raw_current, to_units=self.cellpy_units + ) + else: + factor = 1.0 + + step_keys = [step_cycle] + summary_keys = [ch.cycle_num] + if sh.test_id in steps.columns and ch.test_id in summary.columns: + step_keys = [sh.test_id, step_cycle] + summary_keys = [ch.test_id, ch.cycle_num] + + for step_type, out_name in ( + ("charge", ch.charge_c_rate), + ("discharge", ch.discharge_c_rate), + ): + if out_name not in summary.columns: + continue + subset = steps.loc[steps[type_col] == step_type] + if subset.empty: + summary[out_name] = np.nan + continue + first = subset.drop_duplicates(subset=step_keys, keep="first") + if len(summary_keys) == 1: + lookup = first.set_index(step_keys[0])[rate_col].to_dict() + mapped = summary[summary_keys[0]].map(lookup).to_numpy() + else: + lookup = first.set_index(step_keys)[rate_col].to_dict() + keys = list(zip(*(summary[key].tolist() for key in summary_keys))) + mapped = np.array( + [lookup.get(key, np.nan) for key in keys], dtype=float + ) + summary[out_name] = mapped * factor + return data + def make_summary( self, find_ir=False, diff --git a/tests/test_collected_summary_axes.py b/tests/test_collected_summary_axes.py index 1d9b018c..4caae145 100644 --- a/tests/test_collected_summary_axes.py +++ b/tests/test_collected_summary_axes.py @@ -1,299 +1,303 @@ -"""Collected summary facet axes: share_y / match_axes / y_ranges (#804).""" - -from __future__ import annotations - -import pandas as pd -import pytest - -from cellpy.plotting.collected import _resolve_share_y - - -def _summary_frame() -> pd.DataFrame: - """Long summary frame with capacity-like and CE-like scales.""" - 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": 1.0e6 if cycle == 2 else 98.0, - } - ) - return pd.DataFrame(rows) - - -@pytest.mark.essential -def test_resolve_share_y_prefers_share_y_over_match_axes(): - assert _resolve_share_y(share_y=False, match_axes=True, default=True) is False - assert _resolve_share_y(share_y=True, match_axes=False, default=False) is True - assert _resolve_share_y(share_y=None, match_axes=None, default=False) is False - assert _resolve_share_y(share_y=None, match_axes=True, default=False) is True - - -@pytest.mark.essential -def test_summary_default_independent_y_axes(): - 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 - assert fig.layout.yaxis.matches in (None, False) - assert fig.layout.yaxis2.matches in (None, False) - - -@pytest.mark.essential -def test_summary_share_y_true_matches_axes(): - 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, share_y=True - ) - assert fig is not None - # Plotly links secondary facet rows to the primary y-axis. - assert fig.layout.yaxis2.matches == "y" - - -@pytest.mark.essential -def test_summary_match_axes_alias_and_share_y_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, - match_axes=True, - share_y=False, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - - -@pytest.mark.essential -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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _summary_frame(), - backend="plotly", - group_cells=False, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - 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] - assert fig.layout[ce_key].autorange is False - # Capacity panel left to autorange (no fixed range from y_ranges). - assert fig.layout[cap_key].range is None - - -@pytest.mark.essential -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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _summary_frame(), - backend="plotly", - group_cells=False, - share_y=True, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -@pytest.mark.essential -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_variable, - collected_plot, - ) - - theme.make_collector_templates() - fig = collected_plot( - _summary_frame(), - family_kind="summary", - backend="plotly", - group_cells=False, - y_ranges={"coulombic_efficiency": [0.0, 110.0]}, - ) - assert fig is not None - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -def _group_avg_summary_frame() -> pd.DataFrame: - """Long group-averaged summary frame (mean/std, no cell) for spread path.""" - rows = [] - for group in (1, 2): - for cycle in (1, 2, 3): - rows.append( - { - "cycle": cycle, - "group": group, - "sub_group": 1, - "variable": "charge_capacity_gravimetric", - "mean": 100.0 + cycle + group, - "std": 2.0, - } - ) - rows.append( - { - "cycle": cycle, - "group": group, - "sub_group": 1, - "variable": "coulombic_efficiency", - "mean": 1.0e6 if cycle == 2 else 98.0, - "std": 0.5, - } - ) - return pd.DataFrame(rows) - - -@pytest.mark.essential -def test_spread_share_y_true_matches_axes(): - """Group avg + Spread + share_y links secondary facet y-axes (#817).""" - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - share_y=True, - ) - assert fig is not None - assert fig.layout.yaxis2.matches == "y" - - -@pytest.mark.essential -def test_spread_default_independent_y_axes(): - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - ) - assert fig is not None - assert fig.layout.yaxis.matches in (None, False) - assert fig.layout.yaxis2.matches in (None, False) - - -@pytest.mark.essential -def test_spread_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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - share_y=True, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert ce_key is not None - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -@pytest.mark.essential -def test_collected_plot_spread_share_y(): - 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( - _group_avg_summary_frame(), - family_kind="summary", - backend="plotly", - spread=True, - share_y=True, - ) - assert fig is not None - assert fig.layout.yaxis2.matches == "y" - - -@pytest.mark.essential -def test_spread_mean_traces_have_hovertemplate(): - """Mean hover matches group_it fields + std; bounds skip hover (#875).""" - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - ) - mean_templates = [] - for trace in fig.data: - name = trace.name or "" - if name.startswith("Upper Bound") or name.startswith("Lower Bound"): - assert trace.hoverinfo == "skip" - continue - if trace.legend == "legend2": # direction style entries (#1009) - assert trace.hoverinfo == "skip" - continue - tmpl = trace.hovertemplate or "" - mean_templates.append(tmpl) - assert "mean=%{y}" in tmpl - assert "variable=" in tmpl - assert "Cycle (n.)=%{x}" in tmpl - assert "std=%{customdata}" in tmpl - assert "group=" in tmpl - assert mean_templates, "expected at least one mean trace with hovertemplate" +"""Collected summary facet axes: share_y / match_axes / y_ranges (#804).""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from cellpy.plotting.collected import _resolve_share_y + + +def _summary_frame() -> pd.DataFrame: + """Long summary frame with capacity-like and CE-like scales.""" + 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": 1.0e6 if cycle == 2 else 98.0, + } + ) + return pd.DataFrame(rows) + + +@pytest.mark.essential +def test_resolve_share_y_prefers_share_y_over_match_axes(): + assert _resolve_share_y(share_y=False, match_axes=True, default=True) is False + assert _resolve_share_y(share_y=True, match_axes=False, default=False) is True + assert _resolve_share_y(share_y=None, match_axes=None, default=False) is False + assert _resolve_share_y(share_y=None, match_axes=True, default=False) is True + + +@pytest.mark.essential +def test_summary_default_independent_y_axes(): + 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 + assert fig.layout.yaxis.matches in (None, False) + assert fig.layout.yaxis2.matches in (None, False) + # Cycle number stays locked across facets when y-scales are independent. + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_summary_share_y_true_matches_axes(): + 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, share_y=True + ) + assert fig is not None + # Plotly links secondary facet rows to the primary y-axis. + assert fig.layout.yaxis2.matches == "y" + + +@pytest.mark.essential +def test_summary_match_axes_alias_and_share_y_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, + match_axes=True, + share_y=False, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + + +@pytest.mark.essential +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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + 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] + assert fig.layout[ce_key].autorange is False + # Capacity panel left to autorange (no fixed range from y_ranges). + assert fig.layout[cap_key].range is None + + +@pytest.mark.essential +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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + share_y=True, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +@pytest.mark.essential +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_variable, + collected_plot, + ) + + theme.make_collector_templates() + fig = collected_plot( + _summary_frame(), + family_kind="summary", + backend="plotly", + group_cells=False, + y_ranges={"coulombic_efficiency": [0.0, 110.0]}, + ) + assert fig is not None + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +def _group_avg_summary_frame() -> pd.DataFrame: + """Long group-averaged summary frame (mean/std, no cell) for spread path.""" + rows = [] + for group in (1, 2): + for cycle in (1, 2, 3): + rows.append( + { + "cycle": cycle, + "group": group, + "sub_group": 1, + "variable": "charge_capacity_gravimetric", + "mean": 100.0 + cycle + group, + "std": 2.0, + } + ) + rows.append( + { + "cycle": cycle, + "group": group, + "sub_group": 1, + "variable": "coulombic_efficiency", + "mean": 1.0e6 if cycle == 2 else 98.0, + "std": 0.5, + } + ) + return pd.DataFrame(rows) + + +@pytest.mark.essential +def test_spread_share_y_true_matches_axes(): + """Group avg + Spread + share_y links secondary facet y-axes (#817).""" + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + share_y=True, + ) + assert fig is not None + assert fig.layout.yaxis2.matches == "y" + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_spread_default_independent_y_axes(): + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + ) + assert fig is not None + assert fig.layout.yaxis.matches in (None, False) + assert fig.layout.yaxis2.matches in (None, False) + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_spread_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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + share_y=True, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert ce_key is not None + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +@pytest.mark.essential +def test_collected_plot_spread_share_y(): + 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( + _group_avg_summary_frame(), + family_kind="summary", + backend="plotly", + spread=True, + share_y=True, + ) + assert fig is not None + assert fig.layout.yaxis2.matches == "y" + + +@pytest.mark.essential +def test_spread_mean_traces_have_hovertemplate(): + """Mean hover matches group_it fields + std; bounds skip hover (#875).""" + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + ) + mean_templates = [] + for trace in fig.data: + name = trace.name or "" + if name.startswith("Upper Bound") or name.startswith("Lower Bound"): + assert trace.hoverinfo == "skip" + continue + if trace.legend == "legend2": # direction style entries (#1009) + assert trace.hoverinfo == "skip" + continue + tmpl = trace.hovertemplate or "" + mean_templates.append(tmpl) + assert "mean=%{y}" in tmpl + assert "variable=" in tmpl + assert "Cycle (n.)=%{x}" in tmpl + assert "std=%{customdata}" in tmpl + assert "group=" in tmpl + assert mean_templates, "expected at least one mean trace with hovertemplate" diff --git a/tests/test_refresh_after_meta.py b/tests/test_refresh_after_meta.py index 719a9678..b7f23dc6 100644 --- a/tests/test_refresh_after_meta.py +++ b/tests/test_refresh_after_meta.py @@ -1,57 +1,78 @@ -"""Tests for SUMMARY_META_DEPENDENCIES and CellpyCell.refresh_after (#846).""" - -import pytest - -from cellpy.readers.cellreader import ( - SUMMARY_META_DEPENDENCIES, - normalize_summary_meta_fields, -) - - -def test_summary_meta_dependencies_keys(): - assert set(SUMMARY_META_DEPENDENCIES) == { - "mass", - "active_electrode_area", - "nominal_capacity", - "cycle_mode", - } - for entry in SUMMARY_META_DEPENDENCIES.values(): - assert entry["affects"] - assert entry["notes"] - - -def test_normalize_summary_meta_fields_aliases(): - assert normalize_summary_meta_fields("active_mass") == ("mass",) - assert normalize_summary_meta_fields(("area", "nom_cap")) == ( - "active_electrode_area", - "nominal_capacity", - ) - assert normalize_summary_meta_fields(None) == tuple(SUMMARY_META_DEPENDENCIES) - - -def test_normalize_summary_meta_fields_unknown(): - with pytest.raises(ValueError, match="Unknown summary meta field"): - normalize_summary_meta_fields("temperature") - - -@pytest.mark.essential -def test_refresh_after_mass_updates_gravimetric(dataset): - h = dataset.schema.summary - grav_col = f"{h.charge_capacity}_gravimetric" - assert grav_col in dataset.data.summary.columns - - before = dataset.data.summary[grav_col].copy() - old_mass = float(dataset.mass) - dataset.mass = old_mass * 2.0 - dataset.refresh_after(("mass",)) - - after = dataset.data.summary[grav_col] - # Doubling mass halves gravimetric capacity (factor ∝ 1/mass). - ratio = (before / after).dropna() - assert ratio.notna().any() - assert (ratio - 2.0).abs().max() < 1e-6 - - -def test_refresh_after_unknown_field_raises(dataset): - with pytest.raises(ValueError, match="Unknown summary meta field"): - dataset.refresh_after(("temperature",)) +"""Tests for SUMMARY_META_DEPENDENCIES and CellpyCell.refresh_after (#846).""" + +import pytest + +from cellpy.readers.cellreader import ( + SUMMARY_META_DEPENDENCIES, + normalize_summary_meta_fields, +) + + +def test_summary_meta_dependencies_keys(): + assert set(SUMMARY_META_DEPENDENCIES) == { + "mass", + "active_electrode_area", + "nominal_capacity", + "cycle_mode", + } + for entry in SUMMARY_META_DEPENDENCIES.values(): + assert entry["affects"] + assert entry["notes"] + + +def test_normalize_summary_meta_fields_aliases(): + assert normalize_summary_meta_fields("active_mass") == ("mass",) + assert normalize_summary_meta_fields(("area", "nom_cap")) == ( + "active_electrode_area", + "nominal_capacity", + ) + assert normalize_summary_meta_fields(None) == tuple(SUMMARY_META_DEPENDENCIES) + + +def test_normalize_summary_meta_fields_unknown(): + with pytest.raises(ValueError, match="Unknown summary meta field"): + normalize_summary_meta_fields("temperature") + + +@pytest.mark.essential +def test_refresh_after_mass_updates_gravimetric(dataset): + h = dataset.schema.summary + grav_col = f"{h.charge_capacity}_gravimetric" + assert grav_col in dataset.data.summary.columns + + before = dataset.data.summary[grav_col].copy() + old_mass = float(dataset.mass) + dataset.mass = old_mass * 2.0 + dataset.refresh_after(("mass",)) + + after = dataset.data.summary[grav_col] + # Doubling mass halves gravimetric capacity (factor ∝ 1/mass). + ratio = (before / after).dropna() + assert ratio.notna().any() + assert (ratio - 2.0).abs().max() < 1e-6 + + +def test_refresh_after_unknown_field_raises(dataset): + with pytest.raises(ValueError, match="Unknown summary meta field"): + dataset.refresh_after(("temperature",)) + + +@pytest.mark.essential +def test_refresh_after_nominal_capacity_updates_c_rate(dataset): + """Doubling nominal capacity halves C-rate (rate = current / capacity).""" + h = dataset.schema.summary + col = h.charge_c_rate + assert col in dataset.data.summary.columns + before = dataset.data.summary[col].astype(float) + assert before.abs().max() > 0 + + dataset.nominal_capacity = float(dataset.nominal_capacity) * 2.0 + dataset.refresh_after(("nominal_capacity",)) + + after = dataset.data.summary[col].astype(float) + mask = before.abs() > 1e-6 + ratio = (before[mask] / after[mask]).dropna() + assert not ratio.empty + # Step C-rate is rounded to 5 decimals before the current-unit factor, + # so the ratio is 2 within that rounding, not bit-exact. + assert (ratio - 2.0).abs().max() < 1e-2 From f390cfb72cd9efa877c291dbdc8dccad7d20fd57 Mon Sep 17 00:00:00 2001 From: jepegit Date: Sat, 3 Oct 2026 21:38:34 +0200 Subject: [PATCH 2/2] Prepare 2.1.5.post7. Record the shared summary x-axis and C-rate refresh in HISTORY, and keep the test edits on LF. Co-authored-by: Cursor --- HISTORY.md | 11 + tests/test_collected_summary_axes.py | 606 +++++++++++++-------------- tests/test_refresh_after_meta.py | 156 +++---- 3 files changed, 392 insertions(+), 381 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index ea86644e..073da173 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [2.1.5.post7] - 2026-10-03 + +* Summary facet rows keep a shared cycle axis when each row has its own + y-scale. `share_y=False` no longer clears x-axis linking, including the + group-average plot with spread. + +* `refresh_after` recomputes step and summary C-rates after a mass, area, or + nominal-capacity change. The rate is step current divided by the new + absolute nominal capacity, using the same current-unit factor as + `make_summary`. + * API reference: docstring examples render as highlighted code again. Eleven docstrings used a singular `Example:` section title, which griffe does not recognise, so the block became a markdown admonition and the diff --git a/tests/test_collected_summary_axes.py b/tests/test_collected_summary_axes.py index 4caae145..0ca0c5b6 100644 --- a/tests/test_collected_summary_axes.py +++ b/tests/test_collected_summary_axes.py @@ -1,303 +1,303 @@ -"""Collected summary facet axes: share_y / match_axes / y_ranges (#804).""" - -from __future__ import annotations - -import pandas as pd -import pytest - -from cellpy.plotting.collected import _resolve_share_y - - -def _summary_frame() -> pd.DataFrame: - """Long summary frame with capacity-like and CE-like scales.""" - 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": 1.0e6 if cycle == 2 else 98.0, - } - ) - return pd.DataFrame(rows) - - -@pytest.mark.essential -def test_resolve_share_y_prefers_share_y_over_match_axes(): - assert _resolve_share_y(share_y=False, match_axes=True, default=True) is False - assert _resolve_share_y(share_y=True, match_axes=False, default=False) is True - assert _resolve_share_y(share_y=None, match_axes=None, default=False) is False - assert _resolve_share_y(share_y=None, match_axes=True, default=False) is True - - -@pytest.mark.essential -def test_summary_default_independent_y_axes(): - 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 - assert fig.layout.yaxis.matches in (None, False) - assert fig.layout.yaxis2.matches in (None, False) - # Cycle number stays locked across facets when y-scales are independent. - assert fig.layout.xaxis2.matches == "x" - - -@pytest.mark.essential -def test_summary_share_y_true_matches_axes(): - 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, share_y=True - ) - assert fig is not None - # Plotly links secondary facet rows to the primary y-axis. - assert fig.layout.yaxis2.matches == "y" - - -@pytest.mark.essential -def test_summary_match_axes_alias_and_share_y_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, - match_axes=True, - share_y=False, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - - -@pytest.mark.essential -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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _summary_frame(), - backend="plotly", - group_cells=False, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - 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] - assert fig.layout[ce_key].autorange is False - # Capacity panel left to autorange (no fixed range from y_ranges). - assert fig.layout[cap_key].range is None - - -@pytest.mark.essential -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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _summary_frame(), - backend="plotly", - group_cells=False, - share_y=True, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -@pytest.mark.essential -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_variable, - collected_plot, - ) - - theme.make_collector_templates() - fig = collected_plot( - _summary_frame(), - family_kind="summary", - backend="plotly", - group_cells=False, - y_ranges={"coulombic_efficiency": [0.0, 110.0]}, - ) - assert fig is not None - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -def _group_avg_summary_frame() -> pd.DataFrame: - """Long group-averaged summary frame (mean/std, no cell) for spread path.""" - rows = [] - for group in (1, 2): - for cycle in (1, 2, 3): - rows.append( - { - "cycle": cycle, - "group": group, - "sub_group": 1, - "variable": "charge_capacity_gravimetric", - "mean": 100.0 + cycle + group, - "std": 2.0, - } - ) - rows.append( - { - "cycle": cycle, - "group": group, - "sub_group": 1, - "variable": "coulombic_efficiency", - "mean": 1.0e6 if cycle == 2 else 98.0, - "std": 0.5, - } - ) - return pd.DataFrame(rows) - - -@pytest.mark.essential -def test_spread_share_y_true_matches_axes(): - """Group avg + Spread + share_y links secondary facet y-axes (#817).""" - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - share_y=True, - ) - assert fig is not None - assert fig.layout.yaxis2.matches == "y" - assert fig.layout.xaxis2.matches == "x" - - -@pytest.mark.essential -def test_spread_default_independent_y_axes(): - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - ) - assert fig is not None - assert fig.layout.yaxis.matches in (None, False) - assert fig.layout.yaxis2.matches in (None, False) - assert fig.layout.xaxis2.matches == "x" - - -@pytest.mark.essential -def test_spread_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_variable, - summary_plotter, - ) - - theme.make_collector_templates() - fig = summary_plotter( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - share_y=True, - y_ranges={"coulombic_efficiency": [0, 110]}, - ) - assert fig is not None - assert fig.layout.yaxis2.matches in (None, False) - ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") - assert ce_key is not None - assert list(fig.layout[ce_key].range) == [0.0, 110.0] - - -@pytest.mark.essential -def test_collected_plot_spread_share_y(): - 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( - _group_avg_summary_frame(), - family_kind="summary", - backend="plotly", - spread=True, - share_y=True, - ) - assert fig is not None - assert fig.layout.yaxis2.matches == "y" - - -@pytest.mark.essential -def test_spread_mean_traces_have_hovertemplate(): - """Mean hover matches group_it fields + std; bounds skip hover (#875).""" - 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( - _group_avg_summary_frame(), - backend="plotly", - spread=True, - ) - mean_templates = [] - for trace in fig.data: - name = trace.name or "" - if name.startswith("Upper Bound") or name.startswith("Lower Bound"): - assert trace.hoverinfo == "skip" - continue - if trace.legend == "legend2": # direction style entries (#1009) - assert trace.hoverinfo == "skip" - continue - tmpl = trace.hovertemplate or "" - mean_templates.append(tmpl) - assert "mean=%{y}" in tmpl - assert "variable=" in tmpl - assert "Cycle (n.)=%{x}" in tmpl - assert "std=%{customdata}" in tmpl - assert "group=" in tmpl - assert mean_templates, "expected at least one mean trace with hovertemplate" +"""Collected summary facet axes: share_y / match_axes / y_ranges (#804).""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from cellpy.plotting.collected import _resolve_share_y + + +def _summary_frame() -> pd.DataFrame: + """Long summary frame with capacity-like and CE-like scales.""" + 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": 1.0e6 if cycle == 2 else 98.0, + } + ) + return pd.DataFrame(rows) + + +@pytest.mark.essential +def test_resolve_share_y_prefers_share_y_over_match_axes(): + assert _resolve_share_y(share_y=False, match_axes=True, default=True) is False + assert _resolve_share_y(share_y=True, match_axes=False, default=False) is True + assert _resolve_share_y(share_y=None, match_axes=None, default=False) is False + assert _resolve_share_y(share_y=None, match_axes=True, default=False) is True + + +@pytest.mark.essential +def test_summary_default_independent_y_axes(): + 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 + assert fig.layout.yaxis.matches in (None, False) + assert fig.layout.yaxis2.matches in (None, False) + # Cycle number stays locked across facets when y-scales are independent. + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_summary_share_y_true_matches_axes(): + 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, share_y=True + ) + assert fig is not None + # Plotly links secondary facet rows to the primary y-axis. + assert fig.layout.yaxis2.matches == "y" + + +@pytest.mark.essential +def test_summary_match_axes_alias_and_share_y_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, + match_axes=True, + share_y=False, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + + +@pytest.mark.essential +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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + 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] + assert fig.layout[ce_key].autorange is False + # Capacity panel left to autorange (no fixed range from y_ranges). + assert fig.layout[cap_key].range is None + + +@pytest.mark.essential +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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _summary_frame(), + backend="plotly", + group_cells=False, + share_y=True, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +@pytest.mark.essential +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_variable, + collected_plot, + ) + + theme.make_collector_templates() + fig = collected_plot( + _summary_frame(), + family_kind="summary", + backend="plotly", + group_cells=False, + y_ranges={"coulombic_efficiency": [0.0, 110.0]}, + ) + assert fig is not None + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +def _group_avg_summary_frame() -> pd.DataFrame: + """Long group-averaged summary frame (mean/std, no cell) for spread path.""" + rows = [] + for group in (1, 2): + for cycle in (1, 2, 3): + rows.append( + { + "cycle": cycle, + "group": group, + "sub_group": 1, + "variable": "charge_capacity_gravimetric", + "mean": 100.0 + cycle + group, + "std": 2.0, + } + ) + rows.append( + { + "cycle": cycle, + "group": group, + "sub_group": 1, + "variable": "coulombic_efficiency", + "mean": 1.0e6 if cycle == 2 else 98.0, + "std": 0.5, + } + ) + return pd.DataFrame(rows) + + +@pytest.mark.essential +def test_spread_share_y_true_matches_axes(): + """Group avg + Spread + share_y links secondary facet y-axes (#817).""" + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + share_y=True, + ) + assert fig is not None + assert fig.layout.yaxis2.matches == "y" + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_spread_default_independent_y_axes(): + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + ) + assert fig is not None + assert fig.layout.yaxis.matches in (None, False) + assert fig.layout.yaxis2.matches in (None, False) + assert fig.layout.xaxis2.matches == "x" + + +@pytest.mark.essential +def test_spread_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_variable, + summary_plotter, + ) + + theme.make_collector_templates() + fig = summary_plotter( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + share_y=True, + y_ranges={"coulombic_efficiency": [0, 110]}, + ) + assert fig is not None + assert fig.layout.yaxis2.matches in (None, False) + ce_key = _yaxis_key_for_variable(fig, "coulombic_efficiency") + assert ce_key is not None + assert list(fig.layout[ce_key].range) == [0.0, 110.0] + + +@pytest.mark.essential +def test_collected_plot_spread_share_y(): + 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( + _group_avg_summary_frame(), + family_kind="summary", + backend="plotly", + spread=True, + share_y=True, + ) + assert fig is not None + assert fig.layout.yaxis2.matches == "y" + + +@pytest.mark.essential +def test_spread_mean_traces_have_hovertemplate(): + """Mean hover matches group_it fields + std; bounds skip hover (#875).""" + 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( + _group_avg_summary_frame(), + backend="plotly", + spread=True, + ) + mean_templates = [] + for trace in fig.data: + name = trace.name or "" + if name.startswith("Upper Bound") or name.startswith("Lower Bound"): + assert trace.hoverinfo == "skip" + continue + if trace.legend == "legend2": # direction style entries (#1009) + assert trace.hoverinfo == "skip" + continue + tmpl = trace.hovertemplate or "" + mean_templates.append(tmpl) + assert "mean=%{y}" in tmpl + assert "variable=" in tmpl + assert "Cycle (n.)=%{x}" in tmpl + assert "std=%{customdata}" in tmpl + assert "group=" in tmpl + assert mean_templates, "expected at least one mean trace with hovertemplate" diff --git a/tests/test_refresh_after_meta.py b/tests/test_refresh_after_meta.py index b7f23dc6..5ca06f1c 100644 --- a/tests/test_refresh_after_meta.py +++ b/tests/test_refresh_after_meta.py @@ -1,78 +1,78 @@ -"""Tests for SUMMARY_META_DEPENDENCIES and CellpyCell.refresh_after (#846).""" - -import pytest - -from cellpy.readers.cellreader import ( - SUMMARY_META_DEPENDENCIES, - normalize_summary_meta_fields, -) - - -def test_summary_meta_dependencies_keys(): - assert set(SUMMARY_META_DEPENDENCIES) == { - "mass", - "active_electrode_area", - "nominal_capacity", - "cycle_mode", - } - for entry in SUMMARY_META_DEPENDENCIES.values(): - assert entry["affects"] - assert entry["notes"] - - -def test_normalize_summary_meta_fields_aliases(): - assert normalize_summary_meta_fields("active_mass") == ("mass",) - assert normalize_summary_meta_fields(("area", "nom_cap")) == ( - "active_electrode_area", - "nominal_capacity", - ) - assert normalize_summary_meta_fields(None) == tuple(SUMMARY_META_DEPENDENCIES) - - -def test_normalize_summary_meta_fields_unknown(): - with pytest.raises(ValueError, match="Unknown summary meta field"): - normalize_summary_meta_fields("temperature") - - -@pytest.mark.essential -def test_refresh_after_mass_updates_gravimetric(dataset): - h = dataset.schema.summary - grav_col = f"{h.charge_capacity}_gravimetric" - assert grav_col in dataset.data.summary.columns - - before = dataset.data.summary[grav_col].copy() - old_mass = float(dataset.mass) - dataset.mass = old_mass * 2.0 - dataset.refresh_after(("mass",)) - - after = dataset.data.summary[grav_col] - # Doubling mass halves gravimetric capacity (factor ∝ 1/mass). - ratio = (before / after).dropna() - assert ratio.notna().any() - assert (ratio - 2.0).abs().max() < 1e-6 - - -def test_refresh_after_unknown_field_raises(dataset): - with pytest.raises(ValueError, match="Unknown summary meta field"): - dataset.refresh_after(("temperature",)) - - -@pytest.mark.essential -def test_refresh_after_nominal_capacity_updates_c_rate(dataset): - """Doubling nominal capacity halves C-rate (rate = current / capacity).""" - h = dataset.schema.summary - col = h.charge_c_rate - assert col in dataset.data.summary.columns - before = dataset.data.summary[col].astype(float) - assert before.abs().max() > 0 - - dataset.nominal_capacity = float(dataset.nominal_capacity) * 2.0 - dataset.refresh_after(("nominal_capacity",)) - - after = dataset.data.summary[col].astype(float) - mask = before.abs() > 1e-6 - ratio = (before[mask] / after[mask]).dropna() - assert not ratio.empty - # Step C-rate is rounded to 5 decimals before the current-unit factor, - # so the ratio is 2 within that rounding, not bit-exact. - assert (ratio - 2.0).abs().max() < 1e-2 +"""Tests for SUMMARY_META_DEPENDENCIES and CellpyCell.refresh_after (#846).""" + +import pytest + +from cellpy.readers.cellreader import ( + SUMMARY_META_DEPENDENCIES, + normalize_summary_meta_fields, +) + + +def test_summary_meta_dependencies_keys(): + assert set(SUMMARY_META_DEPENDENCIES) == { + "mass", + "active_electrode_area", + "nominal_capacity", + "cycle_mode", + } + for entry in SUMMARY_META_DEPENDENCIES.values(): + assert entry["affects"] + assert entry["notes"] + + +def test_normalize_summary_meta_fields_aliases(): + assert normalize_summary_meta_fields("active_mass") == ("mass",) + assert normalize_summary_meta_fields(("area", "nom_cap")) == ( + "active_electrode_area", + "nominal_capacity", + ) + assert normalize_summary_meta_fields(None) == tuple(SUMMARY_META_DEPENDENCIES) + + +def test_normalize_summary_meta_fields_unknown(): + with pytest.raises(ValueError, match="Unknown summary meta field"): + normalize_summary_meta_fields("temperature") + + +@pytest.mark.essential +def test_refresh_after_mass_updates_gravimetric(dataset): + h = dataset.schema.summary + grav_col = f"{h.charge_capacity}_gravimetric" + assert grav_col in dataset.data.summary.columns + + before = dataset.data.summary[grav_col].copy() + old_mass = float(dataset.mass) + dataset.mass = old_mass * 2.0 + dataset.refresh_after(("mass",)) + + after = dataset.data.summary[grav_col] + # Doubling mass halves gravimetric capacity (factor ∝ 1/mass). + ratio = (before / after).dropna() + assert ratio.notna().any() + assert (ratio - 2.0).abs().max() < 1e-6 + + +def test_refresh_after_unknown_field_raises(dataset): + with pytest.raises(ValueError, match="Unknown summary meta field"): + dataset.refresh_after(("temperature",)) + + +@pytest.mark.essential +def test_refresh_after_nominal_capacity_updates_c_rate(dataset): + """Doubling nominal capacity halves C-rate (rate = current / capacity).""" + h = dataset.schema.summary + col = h.charge_c_rate + assert col in dataset.data.summary.columns + before = dataset.data.summary[col].astype(float) + assert before.abs().max() > 0 + + dataset.nominal_capacity = float(dataset.nominal_capacity) * 2.0 + dataset.refresh_after(("nominal_capacity",)) + + after = dataset.data.summary[col].astype(float) + mask = before.abs() > 1e-6 + ratio = (before[mask] / after[mask]).dropna() + assert not ratio.empty + # Step C-rate is rounded to 5 decimals before the current-unit factor, + # so the ratio is 2 within that rounding, not bit-exact. + assert (ratio - 2.0).abs().max() < 1e-2