Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/cellpy/plotting/collected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
115 changes: 106 additions & 9 deletions src/cellpy/readers/cellreader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
},
Expand All @@ -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": {
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions tests/test_collected_summary_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_refresh_after_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading