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/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..0ca0c5b6 100644 --- a/tests/test_collected_summary_axes.py +++ b/tests/test_collected_summary_axes.py @@ -55,6 +55,8 @@ def test_summary_default_independent_y_axes(): 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 @@ -206,6 +208,7 @@ def test_spread_share_y_true_matches_axes(): ) assert fig is not None assert fig.layout.yaxis2.matches == "y" + assert fig.layout.xaxis2.matches == "x" @pytest.mark.essential @@ -223,6 +226,7 @@ def test_spread_default_independent_y_axes(): 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 diff --git a/tests/test_refresh_after_meta.py b/tests/test_refresh_after_meta.py index 719a9678..5ca06f1c 100644 --- a/tests/test_refresh_after_meta.py +++ b/tests/test_refresh_after_meta.py @@ -55,3 +55,24 @@ def test_refresh_after_mass_updates_gravimetric(dataset): 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