diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 2ca30d7c..6698f49f 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -8,13 +8,5 @@ uv run python -m cellpy._deprecation | Name | Replacement | Introduced | Removal | | --- | --- | --- | --- | -| `ica.Converter` | `cellpy.ica.transform_half_cycle with IcaOptions` | 2.0 | 2.1 | -| `ica.dqdv(cycle=...)` | `cellpy.ica.dqdv(cycles=...)` | 2.0 | 2.1 | -| `ica.dqdv(label_direction=...)` | `the direction column, which the specced frame always carries` | 2.0 | 2.1 | -| `ica.dqdv(split=... / tidy=...)` | `cellpy.ica.dqdv(direction=...) and cellpy.ica.to_wide()` | 2.0 | 2.1 | -| `ica.dqdv_cycle` | `cellpy.ica.dqdv (returns the specced long frame)` | 2.0 | 2.1 | -| `ica.dqdv_cycles` | `cellpy.ica.dqdv (returns the specced long frame)` | 2.0 | 2.1 | -| `ica.dqdv_np` | `cellpy.ica.transform_half_cycle with IcaOptions` | 2.0 | 2.1 | | `legacy header attribute access (headers_normal / _summary / _step_table)` | `c.schema.raw / c.schema.steps / c.schema.summary` | 2.0 | 2.1 | | `make_new_cell` | `CellpyCell.vacant` | 2.0 | 2.1 | -| `the 'dq' column of the ica output frame` | `the 'dqdv' column of the same frame` | 2.0 | 2.1 | diff --git a/cellpy/_deprecation.py b/cellpy/_deprecation.py index edf85ffe..8f4f7507 100644 --- a/cellpy/_deprecation.py +++ b/cellpy/_deprecation.py @@ -107,45 +107,9 @@ def _seed_known_deprecations() -> None: # cellpy.utils.easyplot was removed in 2.0 (#544); it is no longer a # pending deprecation, so it is dropped from the registry / DEPRECATIONS.md. - # ICA redesign (#566). The 1.x entry points survive as shims over the new - # pure core and reproduce the old numbers exactly; they warn per call site. - _register( - "ica.Converter", - "cellpy.ica.transform_half_cycle with IcaOptions", - removal="2.1", - ) - _register( - "ica.dqdv_cycle", - "cellpy.ica.dqdv (returns the specced long frame)", - removal="2.1", - ) - _register( - "ica.dqdv_cycles", - "cellpy.ica.dqdv (returns the specced long frame)", - removal="2.1", - ) - _register( - "ica.dqdv_np", - "cellpy.ica.transform_half_cycle with IcaOptions", - removal="2.1", - ) - _register("ica.dqdv(cycle=...)", "cellpy.ica.dqdv(cycles=...)", removal="2.1") - _register( - "ica.dqdv(label_direction=...)", - "the direction column, which the specced frame always carries", - removal="2.1", - ) - _register( - "ica.dqdv(split=... / tidy=...)", - "cellpy.ica.dqdv(direction=...) and cellpy.ica.to_wide()", - removal="2.1", - ) - # The ICA output frame carries both spellings for one release. - _register( - "the 'dq' column of the ica output frame", - "the 'dqdv' column of the same frame", - removal="2.1", - ) + # ICA 1.x shims (Converter, dqdv_cycle/cycles/np, dqdv split=/tidy=/cycle=/ + # label_direction=, the duplicate 'dq' column) were removed in 2.1 (E2, #714) + # -- no longer registered here. # Plotting shims (interactive=, xlim/ylim, backend="seaborn", summary_plot_legacy) # were removed in 2.1 (E1, #713) -- no longer registered here. diff --git a/cellpy/ica.py b/cellpy/ica.py index 1da77828..df8c4c77 100644 --- a/cellpy/ica.py +++ b/cellpy/ica.py @@ -20,15 +20,14 @@ - The output frame is *specced*: always long format, always the same columns, with `direction` spelled `"charge"`/`"discharge"` instead of the old ±1 code whose meaning depended on `cycle_mode`. -- The incremental-capacity column is named `dqdv`. The old name `dq` is kept as - a duplicate column for one release. +- The incremental-capacity column is named `dqdv`. - `dvdq()` is new. cellpy could not compute differential voltage analysis at all before, even though the pipeline already built the smoothed V(q) representation it needs. - Half-cycles that fail are reported, not silently replaced by empty arrays. -- `Converter`, `dqdv_cycle`, `dqdv_cycles`, `dqdv_np`, `dqdv(split=True)` and - `dqdv(tidy=False)` are deprecated shims over the new core. They reproduce the - 1.x numbers bit-for-bit (`tests/data/goldens/ica_dqdv_*`) and go away in 2.1. +- The 1.x surface (`Converter`, `dqdv_cycle`, `dqdv_cycles`, `dqdv_np`, + `dqdv(split=/tidy=/cycle=/label_direction=)`, the duplicate `dq` column) was + removed in 2.1 (#714). Use `dqdv(cell, cycles=, direction=)` + `to_wide()`. scipy stays on this side of the cellpy/cellpycore boundary: cellpycore is scipy-free, and the ICA math is interpolation and filtering, not frame algebra. @@ -52,7 +51,6 @@ from cellpycore.config import CurveCols -from cellpy._deprecation import warn_once from cellpy.exceptions import NullData logger = logging.getLogger(__name__) @@ -63,7 +61,6 @@ __all__ = [ "BOTH", "CHARGE", - "Converter", "DISCHARGE", "DVA_DEFAULTS", "GaussianOptions", @@ -72,9 +69,6 @@ "IcaCols", "IcaOptions", "dqdv", - "dqdv_cycle", - "dqdv_cycles", - "dqdv_np", "dvdq", "index_bounds", "to_wide", @@ -246,8 +240,6 @@ class IcaCols: capacity: str = "capacity" dqdv: str = "dqdv" dvdq: str = "dvdq" - #: Deprecated duplicate of ``dqdv``, kept for one release (removed in 2.1). - legacy_dqdv: str = "dq" def ordered_names(self, derivative: str = "dqdv") -> list[str]: """Column order for the given derivative.""" @@ -684,8 +676,6 @@ def _resolve_source( def _empty_frame(derivative: str) -> pd.DataFrame: cols = ICA_COLS.ordered_names(derivative) - if derivative == "dqdv": - cols = cols + [ICA_COLS.legacy_dqdv] return pd.DataFrame({name: pd.Series(dtype="float64") for name in cols}) @@ -791,10 +781,6 @@ def _transform_all( else: frame = _empty_frame(derivative) - if derivative == "dqdv": - # One release of overlap: `dq` was the 1.x name for this column. - frame[ICA_COLS.legacy_dqdv] = frame[ICA_COLS.dqdv] - frame.attrs["derivative"] = derivative frame.attrs["options"] = options frame.attrs["cycle_mode"] = resolved_mode @@ -836,18 +822,13 @@ def dqdv( Returns: A long frame with columns ``cycle``, ``direction``, ``voltage``, - ``capacity``, ``dqdv`` - plus a deprecated duplicate ``dq`` column that - goes away in 2.1. ``frame.attrs`` carries the options used, the + ``capacity``, ``dqdv``. ``frame.attrs`` carries the options used, the resolved cycle mode, and any per-half-cycle failures. Example: >>> frame = dqdv(c, cycles=[1, 2], voltage_resolution=0.005) >>> charge = frame[frame.direction == "charge"] """ - legacy = _pop_legacy_dqdv_kwargs(overrides) - if legacy is not None: - return _legacy_dqdv(source, cycles, legacy, overrides, number_of_points) - return _transform_all( source, "dqdv", @@ -945,741 +926,3 @@ def to_wide(frame: pd.DataFrame) -> pd.DataFrame: wide.columns.names = ["cycle", "value"] return wide - -# --------------------------------------------------------------------------- -# the deprecated 1.x surface -# --------------------------------------------------------------------------- -# -# Everything below is scheduled for removal in 2.1. It is reimplemented on the -# pure core above rather than kept as a second copy of the math, which is what -# makes tests/data/goldens/ica_dqdv_* meaningful: those oracles were recorded -# against the 1.x code and are reproduced bit-for-bit by these shims. - - -def _options_from_converter(converter: "Converter") -> IcaOptions: - """Snapshot a Converter's mutable attributes as an immutable recipe. - - ``normalizing_roof`` is deliberately dropped: ``Converter.inspect_data`` - has already folded it into ``normalizing_factor``, and applying it a second - time in the core would square it. - """ - return IcaOptions( - voltage_resolution=converter.voltage_resolution, - capacity_resolution=converter.capacity_resolution, - max_points=converter.max_points, - interpolation_method=converter.interpolation_method, - pre_smoothing=converter.pre_smoothing, - diff_smoothing=converter.smoothing, - post_smoothing=converter.post_smoothing, - savgol_window_divisor=converter.savgol_filter_window_divisor_default, - savgol_order=converter.savgol_filter_window_order, - voltage_fwhm=converter.voltage_fwhm, - gaussian=GaussianOptions( - order=converter.gaussian_order, - mode=converter.gaussian_mode, - cval=converter.gaussian_cval, - truncate=converter.gaussian_truncate, - ), - normalize="area" if converter.normalize else False, - normalizing_factor=converter.normalizing_factor, - normalizing_roof=None, - ) - - -class Converter: - """Deprecated staged dQ/dV converter. - - Use [`transform_half_cycle`][cellpy.ica.transform_half_cycle] instead: it - takes the same recipe as an [`IcaOptions`][cellpy.ica.IcaOptions] and - returns its derived quantities rather than writing them back onto itself. - - The five stages (set → inspect → pre-process → increment → post-process) - are kept because they are how the 1.x characterization tests drive the - pipeline, but each now delegates to the pure functions above. - - !!! warning "Hidden state" - ``inspect_data`` overwrites ``normalizing_factor`` from the data, so a - converter reused across half-cycles carries the previous one's - normalization into the next. That behaviour is preserved here for - compatibility; the new core returns the factor instead. - """ - - def __init__( - self, - capacity=None, - voltage=None, - points_pr_split=10, - max_points=None, - voltage_resolution=None, - capacity_resolution=None, - minimum_splits=3, - interpolation_method="linear", - increment_method="diff", - pre_smoothing=False, - smoothing=False, - post_smoothing=True, - normalize=True, - normalizing_factor=None, - normalizing_roof=None, - savgol_filter_window_divisor_default=50, - savgol_filter_window_order=3, - voltage_fwhm=0.01, - gaussian_order=0, - gaussian_mode="reflect", - gaussian_cval=0.0, - gaussian_truncate=4.0, - ): - warn_once( - "ica.Converter", - "cellpy.ica.transform_half_cycle with IcaOptions", - removal="2.1", - ) - self.capacity = capacity - self.voltage = voltage - - self.capacity_preprocessed = None - self.voltage_preprocessed = None - self.capacity_inverted = None - self.voltage_inverted = None - - self.incremental_capacity = None - self._incremental_capacity = None # before smoothing - self.voltage_processed = None - self._voltage_processed = None # before shifting / centering - - self.voltage_inverted_step = None - - self.points_pr_split = points_pr_split - self.max_points = max_points - self.voltage_resolution = voltage_resolution - self.capacity_resolution = capacity_resolution - self.minimum_splits = minimum_splits - self.interpolation_method = interpolation_method - self.increment_method = increment_method - self.pre_smoothing = pre_smoothing - self.smoothing = smoothing - self.post_smoothing = post_smoothing - self.savgol_filter_window_divisor_default = savgol_filter_window_divisor_default - self.savgol_filter_window_order = savgol_filter_window_order - self.voltage_fwhm = voltage_fwhm - self.gaussian_order = gaussian_order - self.gaussian_mode = gaussian_mode - self.gaussian_cval = gaussian_cval - self.gaussian_truncate = gaussian_truncate - self.normalize = normalize - self.normalizing_factor = normalizing_factor - self.normalizing_roof = normalizing_roof - - self.d_capacity_mean = None - self.d_voltage_mean = None - self.len_capacity = None - self.len_voltage = None - self.min_capacity = None - self.max_capacity = None - self.start_capacity = None - self.end_capacity = None - self.number_of_points = None - self.std_err_median = None - self.std_err_mean = None - - self.fixed_voltage_range = False - - self.errors = [] - - def __str__(self): - txt = f"[ica.converter] {str(type(self))}\n" - for name, att in vars(self).items(): - if isinstance(att, (pd.DataFrame, pd.Series, np.ndarray)): - str_att = f" ({str(type(att))})" - else: - str_att = str(att) - txt += f"{name}: {str_att}\n" - return txt - - def set_data(self, capacity, voltage=None, capacity_label="q", voltage_label="v"): - """Set the data.""" - logging.debug("setting data (capacity and voltage)") - if isinstance(capacity, pd.DataFrame): - self.capacity = capacity[capacity_label] - self.voltage = capacity[voltage_label] - else: - assert len(capacity) == len(voltage) - self.capacity = capacity - self.voltage = voltage - - def inspect_data(self, capacity=None, voltage=None, err_est=False, diff_est=False): - """Check and inspect the data.""" - from scipy import stats - - logging.debug("inspecting the data") - - if capacity is None: - capacity = self.capacity - if voltage is None: - voltage = self.voltage - - if capacity is None or voltage is None: - raise NullData - - self.len_capacity = len(capacity) - self.len_voltage = len(voltage) - - if self.len_capacity <= 1: - raise NullData - if self.len_voltage <= 1: - raise NullData - - self.min_capacity, self.max_capacity = value_bounds(capacity) - self.start_capacity, self.end_capacity = index_bounds(capacity) - - self.number_of_points = len(capacity) - - if diff_est: - self.d_capacity_mean = np.mean(np.diff(np.asarray(capacity))) - self.d_voltage_mean = np.mean(np.diff(np.asarray(voltage))) - - if err_est: - splits = int(self.number_of_points / self.points_pr_split) - rest = self.number_of_points % self.points_pr_split - - if splits < self.minimum_splits: - logging.debug("no point in splitting, too little data") - self.errors.append("splitting: to few points") - else: - if rest > 0: - _cap = capacity[:-rest] - _vol = voltage[:-rest] - else: - _cap = capacity - _vol = voltage - - c_pieces = np.split(np.asarray(_cap), splits) - v_pieces = np.split(np.asarray(_vol), splits) - - std_err = [] - for c, v in zip(c_pieces, v_pieces): - std_err.append(stats.linregress(c, v)[4]) - - self.std_err_median = np.median(std_err) - self.std_err_mean = np.mean(std_err) - - if not self.start_capacity == self.min_capacity: - self.errors.append("capacity: start<>min") - - if not self.end_capacity == self.max_capacity: - self.errors.append("capacity: end<>max") - - if self.normalizing_factor is None: - self.normalizing_factor = self.end_capacity - - if self.normalizing_roof is not None: - self.normalizing_factor = ( - self.normalizing_factor * self.end_capacity / self.normalizing_roof - ) - - def pre_process_data(self): - """Interpolate V(q), optionally pre-smoothed.""" - logging.debug("pre-processing the data") - self.capacity_preprocessed, self.voltage_preprocessed = _interpolate_vq( - self.capacity, self.voltage, _options_from_converter(self) - ) - - def increment_data(self): - """Perform the dq-dv transform.""" - logging.debug("incrementing data") - options = _options_from_converter(self) - - ( - self.voltage_inverted, - self.capacity_inverted, - self.voltage_inverted_step, - ) = _invert_qv(self.capacity_preprocessed, self.voltage_preprocessed, options) - - if self.increment_method == "diff": - self.incremental_capacity = _differentiate( - self.capacity_inverted, self.voltage_inverted_step - ) - self._incremental_capacity = self.incremental_capacity - self._voltage_processed = self.voltage_inverted[1:] - self.voltage_processed = _midpoints( - self.voltage_inverted, self.voltage_inverted_step - ) - - elif self.increment_method == "hist": - # Never finished ("assigned to Asbjoern", 2018). Kept only so the - # 1.x characterization test that exercises it still runs; the new - # IcaOptions rejects it outright. See cellpy#566. - logging.warning( - "the 'hist' increment method was never completed and is not " - "available through the new ica API (cellpy#566)" - ) - df = pd.DataFrame( - {"Capacity": self.capacity_inverted, "Voltage": self.voltage_inverted} - ) - df["dQ"] = df.Capacity.diff() - df["Voltage"] = df.Voltage.round(decimals=4) - df = df.groupby(["Voltage"])["dQ"].sum().to_frame().reset_index() - df["dV"] = df.Voltage.diff().rolling(1).sum() - df["dQdV"] = df.dQ / df.dV - - self.incremental_capacity = df.dQdV - self.voltage_processed = df.Voltage - - else: - raise ValueError(f"unknown increment_method: {self.increment_method!r}") - - def post_process_data( - self, voltage=None, incremental_capacity=None, voltage_step=None - ): - """Smooth, normalize and optionally re-grid the finished derivative.""" - logging.debug("post-processing data") - - if voltage is None: - voltage = self.voltage_processed - incremental_capacity = self.incremental_capacity - voltage_step = self.voltage_inverted_step - - options = _options_from_converter(self) - - if options.post_smoothing: - incremental_capacity = _gaussian_smooth( - incremental_capacity, options.voltage_fwhm, voltage_step, options - ) - if options.normalize == "area": - incremental_capacity = _normalize_to_area( - incremental_capacity, voltage, self.normalizing_factor - ) - - self.incremental_capacity = incremental_capacity - - fixed_range = False - if isinstance(self.fixed_voltage_range, np.ndarray): - fixed_range = True - elif self.fixed_voltage_range: - fixed_range = True - - if fixed_range: - logging.debug(" - using fixed voltage range (interpolating)") - v1, v2, number_of_points = self.fixed_voltage_range - v = np.linspace(v1, v2, number_of_points) - f = interp1d( - x=self.voltage_processed, - y=incremental_capacity, - kind=self.interpolation_method, - bounds_error=False, - fill_value=np.nan, - ) - self.incremental_capacity = f(v) - self.voltage_processed = v - - -def _legacy_half_cycle(cycle_df, code, options: IcaOptions): - """Run one legacy half-cycle through the pure core.""" - part = cycle_df.loc[cycle_df[_CCOLS.direction] == code] - result = transform_half_cycle( - part[_CCOLS.potential], part[_CCOLS.capacity], options, derivative="dqdv" - ) - return result.x, result.y - - -def _legacy_options(kwargs: dict[str, Any]) -> IcaOptions: - """Translate the 1.x Converter keyword soup into an IcaOptions.""" - gaussian = GaussianOptions( - order=kwargs.get("gaussian_order", 0), - mode=kwargs.get("gaussian_mode", "reflect"), - cval=kwargs.get("gaussian_cval", 0.0), - truncate=kwargs.get("gaussian_truncate", 4.0), - ) - normalize = kwargs.get("normalize", True) - return IcaOptions( - voltage_resolution=kwargs.get("voltage_resolution"), - capacity_resolution=kwargs.get("capacity_resolution"), - max_points=kwargs.get("max_points"), - interpolation_method=kwargs.get("interpolation_method", "linear"), - pre_smoothing=kwargs.get("pre_smoothing", False), - diff_smoothing=kwargs.get("smoothing", False), - post_smoothing=kwargs.get("post_smoothing", True), - savgol_window_divisor=kwargs.get("savgol_filter_window_divisor_default", 50), - savgol_order=kwargs.get("savgol_filter_window_order", 3), - voltage_fwhm=kwargs.get("voltage_fwhm", 0.01), - gaussian=gaussian, - normalize="area" if normalize else False, - normalizing_factor=kwargs.get("normalizing_factor"), - normalizing_roof=kwargs.get("normalizing_roof"), - ) - - -def dqdv_cycle(cycle_df, splitter=True, label_direction=False, **kwargs): - """Deprecated. Use [`dqdv`][cellpy.ica.dqdv] with a curve frame. - - Returns a tuple of numpy arrays rather than a frame, and reports failure by - substituting empty arrays. - - Args: - cycle_df: One cycle ('potential', 'capacity', 'direction' as ±1). - splitter: Insert a NaN row between the two half-cycles. - label_direction: Also return the ±1 direction array. - """ - warn_once( - "ica.dqdv_cycle", - "cellpy.ica.dqdv (returns the specced long frame)", - removal="2.1", - ) - return _dqdv_cycle_impl( - cycle_df, splitter=splitter, label_direction=label_direction, **kwargs - ) - - -def _warn_dqdv_half_cycle(which: str, exc: BaseException) -> None: - """Log a half-cycle failure once at WARNING; later hits at DEBUG.""" - global _dqdv_half_cycle_warned - msg = "Error in dqdv_cycle - %s half-cycle: %s" - if not _dqdv_half_cycle_warned: - logger.warning( - msg + " Further occurrences in this process are logged at DEBUG.", - which, - exc, - ) - _dqdv_half_cycle_warned = True - else: - logger.debug(msg, which, exc) - - -def _dqdv_cycle_impl(cycle_df, splitter=True, label_direction=False, **kwargs): - if cycle_df.empty: - raise NullData(f"The cycle (type={type(cycle_df)}) is empty.") - - options = _legacy_options(kwargs) - - try: - voltage_first, incremental_first = _legacy_half_cycle(cycle_df, -1, options) - if splitter: - voltage_first = np.append(voltage_first, np.nan) - incremental_first = np.append(incremental_first, np.nan) - except Exception as e: # noqa: BLE001 - 1.x behaviour, preserved - _warn_dqdv_half_cycle("first", e) - voltage_first = np.array([]) - incremental_first = np.array([]) - - try: - voltage_last, incremental_last = _legacy_half_cycle(cycle_df, 1, options) - voltage_last = voltage_last[::-1] - incremental_last = incremental_last[::-1] - except Exception as e: # noqa: BLE001 - 1.x behaviour, preserved - _warn_dqdv_half_cycle("last", e) - voltage_last = np.array([]) - incremental_last = np.array([]) - - voltage = np.concatenate((voltage_first, voltage_last)) - incremental_capacity = np.concatenate((incremental_first, incremental_last)) - - if label_direction: - direction = np.concatenate( - (-np.ones(len(voltage_first)), np.ones(len(voltage_last))) - ) - return voltage, incremental_capacity, direction - - return voltage, incremental_capacity - - -def dqdv_cycles(cycles_df, not_merged=False, label_direction=False, **kwargs): - """Deprecated. Use [`dqdv`][cellpy.ica.dqdv] with a curve frame. - - Args: - cycles_df: Curve frame with a cycle-number column. - not_merged: Return ``(cycle_numbers, frames)`` instead of one frame. - label_direction: Include the ±1 ``direction`` column. - """ - warn_once( - "ica.dqdv_cycles", - "cellpy.ica.dqdv (returns the specced long frame)", - removal="2.1", - ) - return _dqdv_cycles_impl( - cycles_df, not_merged=not_merged, label_direction=label_direction, **kwargs - ) - - -def _dqdv_cycles_impl(cycles_df, not_merged=False, label_direction=False, **kwargs): - if len(cycles_df) < 1: - logging.debug("no curve data to work with") - return pd.DataFrame() - - ica_dfs = [] - keys = [] - for cycle_number, cycle in cycles_df.groupby(_CCOLS.cycle_num): - cycle = cycle.dropna() - try: - if label_direction: - v, dq, direction = _dqdv_cycle_impl( - cycle, splitter=True, label_direction=True, **kwargs - ) - _d = {"voltage": v, "dq": dq, "direction": direction} - _cols = ["voltage", "dq", "direction"] - else: - v, dq = _dqdv_cycle_impl( - cycle, splitter=True, label_direction=False, **kwargs - ) - _d = {"voltage": v, "dq": dq} - _cols = ["voltage", "dq"] - _ica_df = pd.DataFrame(_d) - if not not_merged: - _cols.insert(0, "cycle") - _ica_df["cycle"] = cycle_number - _ica_df = _ica_df[_cols] - else: - keys.append(cycle_number) - _ica_df = _ica_df[_cols] - ica_dfs.append(_ica_df) - except NullData: - logging.debug(f"Could not calculate data for cycle {cycle_number}") - - if not_merged: - return keys, ica_dfs - - return pd.concat(ica_dfs) - - -def dqdv_np( - voltage, - capacity, - voltage_resolution=None, - capacity_resolution=None, - voltage_fwhm=0.01, - pre_smoothing=True, - diff_smoothing=False, - post_smoothing=True, - post_normalization=True, - interpolation_method=None, - gaussian_order=None, - gaussian_mode=None, - gaussian_cval=None, - gaussian_truncate=None, - points_pr_split=None, - savgol_filter_window_divisor_default=None, - savgol_filter_window_order=None, - max_points=None, - **kwargs, -): - """Deprecated. Use [`transform_half_cycle`][cellpy.ica.transform_half_cycle]. - - Returns: - ``(voltage, dqdv)`` as numpy arrays. - """ - warn_once( - "ica.dqdv_np", - "cellpy.ica.transform_half_cycle with IcaOptions", - removal="2.1", - ) - gaussian = GaussianOptions( - order=0 if gaussian_order is None else gaussian_order, - mode="reflect" if gaussian_mode is None else gaussian_mode, - cval=0.0 if gaussian_cval is None else gaussian_cval, - truncate=4.0 if gaussian_truncate is None else gaussian_truncate, - ) - options = IcaOptions( - voltage_resolution=voltage_resolution, - capacity_resolution=capacity_resolution, - max_points=max_points, - interpolation_method=( - "linear" if interpolation_method is None else interpolation_method - ), - pre_smoothing=pre_smoothing, - diff_smoothing=diff_smoothing, - post_smoothing=post_smoothing, - savgol_window_divisor=( - 50 - if savgol_filter_window_divisor_default is None - else savgol_filter_window_divisor_default - ), - savgol_order=( - 3 if savgol_filter_window_order is None else savgol_filter_window_order - ), - voltage_fwhm=voltage_fwhm, - gaussian=gaussian, - normalize="area" if post_normalization else False, - normalizing_factor=kwargs.get("normalizing_factor"), - normalizing_roof=kwargs.get("normalizing_roof"), - ) - result = transform_half_cycle(voltage, capacity, options, derivative="dqdv") - return result.x, result.y - - -def _constrained_dq_dv_using_dataframes(capacity, minimum_v, maximum_v, **kwargs): - """The legacy split path's per-cycle transform, onto a fixed voltage grid.""" - options = _legacy_options(kwargs) - result = transform_half_cycle( - capacity["v"], capacity["q"], options, derivative="dqdv" - ) - v = np.linspace(minimum_v, maximum_v, 100) - f = interp1d( - x=result.x, - y=result.y, - kind=options.interpolation_method, - bounds_error=False, - fill_value=np.nan, - ) - return v, f(v) - - -def _make_ica_charge_curves(cycles_dfs, cycle_numbers, minimum_v, maximum_v, **kwargs): - incremental_charge_list = [] - - for c, n in zip(cycles_dfs, cycle_numbers): - if c.empty: - logging.info(f"{n} is empty") - v = [np.nan] - dq = [np.nan] - else: - v, dq = _constrained_dq_dv_using_dataframes( - c, minimum_v, maximum_v, **kwargs - ) - if not incremental_charge_list: - d = pd.DataFrame({"v": v}) - d.name = "voltage" - incremental_charge_list.append(d) - - d = pd.DataFrame({"dq": dq}) - d.name = n - incremental_charge_list.append(d) - - return incremental_charge_list - - -def _dqdv_split_frames( - cell, - tidy=False, - trim_taper_steps=None, - steps_to_skip=None, - steptable=None, - max_cycle_number=None, - **kwargs, -): - """The legacy ``split=True`` path. - - Kept verbatim in behaviour, including the fact that it reaches the curves - through ``collect_capacity_curves`` in the *readers* package rather than - ``get_cap`` — the asymmetry the redesign exists to remove. - """ - from cellpy.readers.data_structures import collect_capacity_curves - - cycle = kwargs.pop("cycle", None) - if cycle and not isinstance(cycle, (list, tuple)): - cycle = [cycle] - - frames = [] - for direction in ("charge", "discharge"): - dfs, cycles, minimum_v, maximum_v = collect_capacity_curves( - cell, - direction=direction, - trim_taper_steps=trim_taper_steps, - steps_to_skip=steps_to_skip, - steptable=steptable, - max_cycle_number=max_cycle_number, - cycle=cycle, - ) - logging.debug(f"retrieved {len(dfs)} {direction} cycles") - ica_dfs = _make_ica_charge_curves(dfs, cycles, minimum_v, maximum_v, **kwargs) - frame = pd.concat(ica_dfs, axis=1, keys=[k.name for k in ica_dfs]) - frame.columns.names = ["cycle", "value"] - if tidy: - frame = frame.melt( - "voltage", var_name="cycle", value_name="dq", col_level=0 - ) - frames.append(frame) - - return frames[0], frames[1] - - -def _dqdv_combined_frame(cell, tidy=True, label_direction=False, **kwargs): - """The legacy non-split path.""" - cycle = kwargs.pop("cycle", None) - number_of_points = kwargs.pop("number_of_points", None) - cycles = cell.get_cap( - cycle=cycle, - method="forth-and-forth", - categorical_column=True, - label_cycle_number=True, - insert_nan=False, - number_of_points=number_of_points, - ) - - ica_df = _dqdv_cycles_impl( - cycles, not_merged=not tidy, label_direction=label_direction, **kwargs - ) - - if not tidy: - keys, frames = ica_df - return pd.concat(frames, axis=1, keys=keys) - - return ica_df - - -# --- the 1.x dqdv() call patterns ------------------------------------------- -# -# `dqdv` keeps its name but returns the specced frame. The 1.x-only keywords -# that changed the *shape* of the return value route to the old implementation -# and warn, so existing scripts keep working for one release instead of -# silently receiving a differently-shaped frame. - -_LEGACY_DQDV_KWARGS = ("split", "tidy", "label_direction", "cycle") - - -def _pop_legacy_dqdv_kwargs(overrides: dict[str, Any]) -> dict[str, Any] | None: - """Remove 1.x-only keywords from *overrides*; return them, or None.""" - legacy = { - key: overrides.pop(key) for key in _LEGACY_DQDV_KWARGS if key in overrides - } - if not legacy: - return None - - if "cycle" in legacy: - warn_once("ica.dqdv(cycle=...)", "cellpy.ica.dqdv(cycles=...)", removal="2.1") - if "label_direction" in legacy: - warn_once( - "ica.dqdv(label_direction=...)", - "the direction column, which the specced frame always carries", - removal="2.1", - ) - if "split" in legacy or "tidy" in legacy: - warn_once( - "ica.dqdv(split=... / tidy=...)", - "cellpy.ica.dqdv(direction=...) and cellpy.ica.to_wide()", - removal="2.1", - ) - return legacy - - -def _legacy_dqdv( - source, - cycles, - legacy: dict[str, Any], - overrides: dict[str, Any], - number_of_points: int | None, -): - """Reproduce the 1.x ``dqdv`` return shapes.""" - split = legacy.get("split", False) - tidy = legacy.get("tidy", True) - label_direction = legacy.get("label_direction", False) - cycle = legacy.get("cycle", cycles) - - kwargs = dict(overrides) - if cycle is not None: - kwargs["cycle"] = cycle - if number_of_points is not None: - kwargs["number_of_points"] = number_of_points - - if split: - kwargs.pop("number_of_points", None) # the split path never took it - return _dqdv_split_frames(source, tidy=tidy, **kwargs) - - if not tidy or label_direction: - return _dqdv_combined_frame( - source, tidy=tidy, label_direction=label_direction, **kwargs - ) - - # Only `cycle=` was renamed away: the caller did not ask for a legacy - # *shape*, so give them the specced frame. - return _transform_all( - source, "dqdv", cycle, BOTH, None, False, None, number_of_points, overrides - ) diff --git a/cellpy/plotting/prepare/ica.py b/cellpy/plotting/prepare/ica.py index 650d763c..79117de3 100644 --- a/cellpy/plotting/prepare/ica.py +++ b/cellpy/plotting/prepare/ica.py @@ -99,8 +99,6 @@ def prepare( **dict(config.option_overrides or {}), ) frame = frame.copy() - if ICA_COLS.legacy_dqdv in frame.columns: - frame = frame.drop(columns=[ICA_COLS.legacy_dqdv]) if config.derivative == "dvdq": x_col = ICA_COLS.capacity diff --git a/cellpy/utils/ica.py b/cellpy/utils/ica.py index 08ef97c6..2bfcc774 100644 --- a/cellpy/utils/ica.py +++ b/cellpy/utils/ica.py @@ -21,16 +21,12 @@ DISCHARGE, BOTH, DVA_DEFAULTS, - Converter, GaussianOptions, HalfCycleResult, IcaCols, IcaOptions, ICA_COLS, dqdv, - dqdv_cycle, - dqdv_cycles, - dqdv_np, dvdq, index_bounds, to_wide, @@ -41,7 +37,6 @@ __all__ = [ "BOTH", "CHARGE", - "Converter", "DISCHARGE", "DVA_DEFAULTS", "GaussianOptions", @@ -50,9 +45,6 @@ "IcaCols", "IcaOptions", "dqdv", - "dqdv_cycle", - "dqdv_cycles", - "dqdv_np", "dvdq", "index_bounds", "to_wide", diff --git a/dev/regenerate_goldens.py b/dev/regenerate_goldens.py index 26a8d903..f43fb62c 100644 --- a/dev/regenerate_goldens.py +++ b/dev/regenerate_goldens.py @@ -35,7 +35,6 @@ from golden_support import sort_summary_columns # noqa: E402 from loader_golden_support import LOADER_GOLDEN_SPECS, load_loader_snapshot # noqa: E402 from curve_golden_support import CURVE_GOLDEN_CASES, capture_curve_case # noqa: E402 -from ica_golden_support import ICA_GOLDEN_CASES, capture_ica_case # noqa: E402 _SUITES: dict[str, Callable[[Path], None]] = {} @@ -220,28 +219,6 @@ def _regen_curve(out_dir: Path, _case=selected_case) -> None: _register_curve_golden_suites() -def _register_ica_golden_suites() -> None: - for case in ICA_GOLDEN_CASES: - - def _make_regen(selected_case=case): - @register_golden_suite(selected_case.suite) - def _regen_ica(out_dir: Path, _case=selected_case) -> None: - frame, metrics = capture_ica_case(_case) - write_parquet_frame(frame, out_dir / "ica.parquet") - write_json_doc(metrics, out_dir / "metrics.json") - print( - f"[{_case.suite}] wrote ica.parquet ({metrics['n_rows']} rows, " - f"{metrics['n_columns']} cols) and metrics.json" - ) - - return _regen_ica - - _make_regen() - - -_register_ica_golden_suites() - - def _regenerate_suite(name: str, out_root: Path) -> None: if name not in _SUITES: known = ", ".join(sorted(_SUITES)) or "(none registered)" diff --git a/tests/data/goldens/ica_dqdv_combined/ica.parquet b/tests/data/goldens/ica_dqdv_combined/ica.parquet deleted file mode 100644 index 9e3fe1b5..00000000 Binary files a/tests/data/goldens/ica_dqdv_combined/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_combined/metrics.json b/tests/data/goldens/ica_dqdv_combined/metrics.json deleted file mode 100644 index d1fa9616..00000000 --- a/tests/data/goldens/ica_dqdv_combined/metrics.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "columns": [ - "cycle", - "direction", - "dq", - "voltage" - ], - "description": "dqdv(cell) long frame over every cycle", - "n_columns": 4, - "n_rows": 8889, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_combined", - "sums": { - "cycle": 71309.0, - "direction": -623.0, - "dq": -853881.918437, - "voltage": 5120.24992 - } -} diff --git a/tests/data/goldens/ica_dqdv_cycles_labeled/ica.parquet b/tests/data/goldens/ica_dqdv_cycles_labeled/ica.parquet deleted file mode 100644 index 9e3fe1b5..00000000 Binary files a/tests/data/goldens/ica_dqdv_cycles_labeled/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_cycles_labeled/metrics.json b/tests/data/goldens/ica_dqdv_cycles_labeled/metrics.json deleted file mode 100644 index 26d31966..00000000 --- a/tests/data/goldens/ica_dqdv_cycles_labeled/metrics.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "columns": [ - "cycle", - "direction", - "dq", - "voltage" - ], - "description": "dqdv_cycles over every cycle, with direction labels", - "n_columns": 4, - "n_rows": 8889, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_cycles_labeled", - "sums": { - "cycle": 71309.0, - "direction": -623.0, - "dq": -853881.918437, - "voltage": 5120.24992 - } -} diff --git a/tests/data/goldens/ica_dqdv_np_c5/ica.parquet b/tests/data/goldens/ica_dqdv_np_c5/ica.parquet deleted file mode 100644 index 4c5dc28a..00000000 Binary files a/tests/data/goldens/ica_dqdv_np_c5/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_np_c5/metrics.json b/tests/data/goldens/ica_dqdv_np_c5/metrics.json deleted file mode 100644 index a66088b8..00000000 --- a/tests/data/goldens/ica_dqdv_np_c5/metrics.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "columns": [ - "dqdv", - "voltage" - ], - "description": "dqdv_np on the cycle-5 charge curve with default options", - "n_columns": 2, - "n_rows": 213, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_np_c5", - "sums": { - "dqdv": 385562.530595, - "voltage": 122.641048 - } -} diff --git a/tests/data/goldens/ica_dqdv_np_c5_raw/ica.parquet b/tests/data/goldens/ica_dqdv_np_c5_raw/ica.parquet deleted file mode 100644 index b726402a..00000000 Binary files a/tests/data/goldens/ica_dqdv_np_c5_raw/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_np_c5_raw/metrics.json b/tests/data/goldens/ica_dqdv_np_c5_raw/metrics.json deleted file mode 100644 index d9489dc4..00000000 --- a/tests/data/goldens/ica_dqdv_np_c5_raw/metrics.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "columns": [ - "dqdv", - "voltage" - ], - "description": "dqdv_np with every smoothing and the normalization off", - "n_columns": 2, - "n_rows": 213, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_np_c5_raw", - "sums": { - "dqdv": 385219.814289, - "voltage": 122.614594 - } -} diff --git a/tests/data/goldens/ica_dqdv_np_c5_resolution/ica.parquet b/tests/data/goldens/ica_dqdv_np_c5_resolution/ica.parquet deleted file mode 100644 index 9ffb7414..00000000 Binary files a/tests/data/goldens/ica_dqdv_np_c5_resolution/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_np_c5_resolution/metrics.json b/tests/data/goldens/ica_dqdv_np_c5_resolution/metrics.json deleted file mode 100644 index c5e4932b..00000000 --- a/tests/data/goldens/ica_dqdv_np_c5_resolution/metrics.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "columns": [ - "dqdv", - "voltage" - ], - "description": "dqdv_np with voltage_resolution and diff smoothing", - "n_columns": 2, - "n_rows": 169, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_np_c5_resolution", - "sums": { - "dqdv": 305962.74063, - "voltage": 97.306747 - } -} diff --git a/tests/data/goldens/ica_dqdv_split_charge/ica.parquet b/tests/data/goldens/ica_dqdv_split_charge/ica.parquet deleted file mode 100644 index 1d62e548..00000000 Binary files a/tests/data/goldens/ica_dqdv_split_charge/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_split_charge/metrics.json b/tests/data/goldens/ica_dqdv_split_charge/metrics.json deleted file mode 100644 index 448791b0..00000000 --- a/tests/data/goldens/ica_dqdv_split_charge/metrics.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "columns": [ - "cycle", - "dq", - "voltage" - ], - "description": "dqdv(cell, split=True) charge frame (long)", - "n_columns": 3, - "n_rows": 1800, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_split_charge", - "sums": { - "dq": 2966515.586825, - "voltage": 996.551257 - } -} diff --git a/tests/data/goldens/ica_dqdv_split_discharge/ica.parquet b/tests/data/goldens/ica_dqdv_split_discharge/ica.parquet deleted file mode 100644 index 421ecbf1..00000000 Binary files a/tests/data/goldens/ica_dqdv_split_discharge/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_split_discharge/metrics.json b/tests/data/goldens/ica_dqdv_split_discharge/metrics.json deleted file mode 100644 index 043bb3bc..00000000 --- a/tests/data/goldens/ica_dqdv_split_discharge/metrics.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "columns": [ - "cycle", - "dq", - "voltage" - ], - "description": "dqdv(cell, split=True) discharge frame (long)", - "n_columns": 3, - "n_rows": 1800, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_split_discharge", - "sums": { - "dq": -881143.360687, - "voltage": 2600.809121 - } -} diff --git a/tests/data/goldens/ica_dqdv_wide/ica.parquet b/tests/data/goldens/ica_dqdv_wide/ica.parquet deleted file mode 100644 index abcedbf4..00000000 Binary files a/tests/data/goldens/ica_dqdv_wide/ica.parquet and /dev/null differ diff --git a/tests/data/goldens/ica_dqdv_wide/metrics.json b/tests/data/goldens/ica_dqdv_wide/metrics.json deleted file mode 100644 index b51fd459..00000000 --- a/tests/data/goldens/ica_dqdv_wide/metrics.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "columns": [ - "10::dq", - "10::voltage", - "11::dq", - "11::voltage", - "12::dq", - "12::voltage", - "13::dq", - "13::voltage", - "14::dq", - "14::voltage", - "15::dq", - "15::voltage", - "16::dq", - "16::voltage", - "17::dq", - "17::voltage", - "18::dq", - "18::voltage", - "1::dq", - "1::voltage", - "2::dq", - "2::voltage", - "3::dq", - "3::voltage", - "4::dq", - "4::voltage", - "5::dq", - "5::voltage", - "6::dq", - "6::voltage", - "7::dq", - "7::voltage", - "8::dq", - "8::voltage", - "9::dq", - "9::voltage" - ], - "description": "dqdv(cell, tidy=False) wide MultiIndex frame (flattened)", - "n_columns": 36, - "n_rows": 1104, - "source": "testdata/data/20160805_test001_45_cc_01.res", - "suite": "ica_dqdv_wide", - "sums": { - "10::dq": -59792.366708, - "10::voltage": 214.614264, - "11::dq": -61866.788818, - "11::voltage": 214.896029, - "12::dq": -63209.020359, - "12::voltage": 214.593501, - "13::dq": -68039.348481, - "13::voltage": 217.341417, - "14::dq": -69560.880805, - "14::voltage": 215.956848, - "15::dq": -65944.866701, - "15::voltage": 214.895141, - "16::dq": -75031.102638, - "16::voltage": 216.897122, - "17::dq": -65196.038927, - "17::voltage": 211.204566, - "18::dq": -51561.824257, - "18::voltage": 49.933855, - "1::dq": 180294.742408, - "1::voltage": 1274.310419, - "2::dq": -45417.159569, - "2::voltage": 382.501264, - "3::dq": -48041.55037, - "3::voltage": 382.64469, - "4::dq": -59997.329547, - "4::voltage": 228.459041, - "5::dq": -58058.175301, - "5::voltage": 216.584776, - "6::dq": -59591.959571, - "6::voltage": 216.477947, - "7::dq": -59607.882522, - "7::voltage": 215.579585, - "8::dq": -58058.285736, - "8::voltage": 215.130033, - "9::dq": -65202.080535, - "9::voltage": 218.229422 - } -} diff --git a/tests/ica_golden_support.py b/tests/ica_golden_support.py index fe7798ea..56ce4c39 100644 --- a/tests/ica_golden_support.py +++ b/tests/ica_golden_support.py @@ -1,39 +1,27 @@ -"""ICA golden snapshot helpers (#566 Phase 0). +"""Shared golden-test helper: load the canonical Arbin cell. -These capture the **numeric** output of the pre-redesign ``cellpy.utils.ica`` -entry points so the redesign can be verified as a move rather than a rewrite. -The interpolate → smooth → invert → diff → smooth → normalize recipe is -order-sensitive; shape assertions would not have caught a reordering, so these -oracles record values. +The ICA golden suite proper (``ICA_GOLDEN_CASES`` / ``test_ica_goldens.py``) +recorded the *pre-redesign* ``cellpy.utils.ica`` entry points. Those 1.x shims +were removed in 2.1 (E2, #714), so the suite and its ``tests/data/goldens/ +ica_dqdv_*`` oracles went with them. The modern ``dqdv``/``dvdq`` numeric oracle +lives in ``test_ica_api.py``; the specced-frame checks in ``test_ica.py``. -Every case deliberately goes through the *old* public entry points. After the -redesign those are deprecation shims over the new core, and - -```shell -uv run python dev/regenerate_goldens.py --verify -``` - -then proves the shims still produce bit-identical numbers. +This module now only hosts :func:`load_golden_cell`, the canonical cell loader +shared by the curve goldens and the figure-spec support. """ from __future__ import annotations -from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable - -import numpy as np -import pandas as pd from cellpy import cellreader REPO_ROOT = Path(__file__).resolve().parents[1] -GOLDENS_ROOT = REPO_ROOT / "tests" / "data" / "goldens" RES_FILE = REPO_ROOT / "testdata" / "data" / "20160805_test001_45_cc_01.res" def load_golden_cell() -> cellreader.CellpyCell: - """Load the canonical Arbin cell used for the ICA goldens.""" + """Load the canonical Arbin cell used for the goldens.""" if not RES_FILE.is_file(): raise FileNotFoundError(f"Missing source file {RES_FILE}") cell = cellreader.CellpyCell() @@ -42,238 +30,3 @@ def load_golden_cell() -> cellreader.CellpyCell: cell.make_step_table() cell.make_summary() return cell - - -def _flatten_wide(frame: pd.DataFrame) -> pd.DataFrame: - """Flatten a wide MultiIndex ICA frame for parquet. - - ``dqdv(tidy=False)`` returns columns as ``(cycle, value)`` pairs, which - parquet cannot express. Joining them with ``"::"`` keeps both levels - visible in the committed artifact. - """ - if not isinstance(frame.columns, pd.MultiIndex): - raise TypeError( - "expected MultiIndex columns; a flat frame would be flattened " - "character-by-character" - ) - out = frame.copy() - out.columns = ["::".join(str(part) for part in col) for col in out.columns] - return out.reset_index(drop=True) - - -# --- capture functions ------------------------------------------------------- -# One per suite. Each returns a plain DataFrame of float columns. - - -def _capture_dqdv_np_c5(cell: cellreader.CellpyCell) -> pd.DataFrame: - from cellpy.utils import ica - - capacity, voltage = cell.get_ccap(5, as_frame=False) - v, dqdv = ica.dqdv_np(voltage, capacity) - return pd.DataFrame({"voltage": np.asarray(v), "dqdv": np.asarray(dqdv)}) - - -def _capture_dqdv_np_c5_no_smoothing(cell: cellreader.CellpyCell) -> pd.DataFrame: - from cellpy.utils import ica - - capacity, voltage = cell.get_ccap(5, as_frame=False) - v, dqdv = ica.dqdv_np( - voltage, - capacity, - pre_smoothing=False, - post_smoothing=False, - post_normalization=False, - ) - return pd.DataFrame({"voltage": np.asarray(v), "dqdv": np.asarray(dqdv)}) - - -def _capture_dqdv_np_c5_resolution(cell: cellreader.CellpyCell) -> pd.DataFrame: - """Exercises the voltage_resolution / diff_smoothing branches.""" - from cellpy.utils import ica - - capacity, voltage = cell.get_ccap(5, as_frame=False) - v, dqdv = ica.dqdv_np( - voltage, - capacity, - voltage_resolution=0.005, - diff_smoothing=True, - voltage_fwhm=0.02, - ) - return pd.DataFrame({"voltage": np.asarray(v), "dqdv": np.asarray(dqdv)}) - - -def _capture_dqdv_cycles_labeled(cell: cellreader.CellpyCell) -> pd.DataFrame: - from cellpy.utils import ica - - cycles = cell.get_cap( - method="forth-and-forth", - categorical_column=True, - label_cycle_number=True, - insert_nan=False, - ) - return ica.dqdv_cycles(cycles, label_direction=True).reset_index(drop=True) - - -def _capture_dqdv_combined(cell: cellreader.CellpyCell) -> pd.DataFrame: - from cellpy.utils import ica - - return ica.dqdv(cell, label_direction=True).reset_index(drop=True) - - -def _capture_dqdv_split_charge(cell: cellreader.CellpyCell) -> pd.DataFrame: - """The split path — note it reaches the curves through - ``collect_capacity_curves``, not ``get_cap``, and interpolates onto a fixed - 100-point voltage range. Unifying those two extraction routes is the point - of the redesign, so this oracle is what "unified" has to reproduce.""" - from cellpy.utils import ica - - charge, _ = ica.dqdv(cell, split=True) - return charge.reset_index(drop=True) - - -def _capture_dqdv_split_discharge(cell: cellreader.CellpyCell) -> pd.DataFrame: - from cellpy.utils import ica - - _, discharge = ica.dqdv(cell, split=True) - return discharge.reset_index(drop=True) - - -def _capture_dqdv_wide(cell: cellreader.CellpyCell) -> pd.DataFrame: - """The wide MultiIndex output of ``dqdv(tidy=False)``.""" - from cellpy.utils import ica - - return _flatten_wide(ica.dqdv(cell, tidy=False)) - - -@dataclass(frozen=True) -class IcaGoldenCase: - """One committed ICA oracle.""" - - suite: str - description: str - capture: Callable[[cellreader.CellpyCell], pd.DataFrame] - kwargs: dict[str, Any] = field(default_factory=dict) - - @property - def golden_dir(self) -> Path: - return GOLDENS_ROOT / self.suite - - def artifacts_present(self) -> bool: - return (self.golden_dir / "ica.parquet").is_file() and ( - self.golden_dir / "metrics.json" - ).is_file() - - def skip_reason(self) -> str | None: - if not RES_FILE.is_file(): - return f"source file missing: {RES_FILE.relative_to(REPO_ROOT)}" - if not self.artifacts_present(): - return ( - f"golden artifacts missing under " - f"{self.golden_dir.relative_to(REPO_ROOT)}" - ) - return None - - -ICA_GOLDEN_CASES: tuple[IcaGoldenCase, ...] = ( - IcaGoldenCase( - suite="ica_dqdv_np_c5", - description="dqdv_np on the cycle-5 charge curve with default options", - capture=_capture_dqdv_np_c5, - ), - IcaGoldenCase( - suite="ica_dqdv_np_c5_raw", - description="dqdv_np with every smoothing and the normalization off", - capture=_capture_dqdv_np_c5_no_smoothing, - ), - IcaGoldenCase( - suite="ica_dqdv_np_c5_resolution", - description="dqdv_np with voltage_resolution and diff smoothing", - capture=_capture_dqdv_np_c5_resolution, - ), - IcaGoldenCase( - suite="ica_dqdv_cycles_labeled", - description="dqdv_cycles over every cycle, with direction labels", - capture=_capture_dqdv_cycles_labeled, - ), - IcaGoldenCase( - suite="ica_dqdv_combined", - description="dqdv(cell) long frame over every cycle", - capture=_capture_dqdv_combined, - ), - IcaGoldenCase( - suite="ica_dqdv_split_charge", - description="dqdv(cell, split=True) charge frame (long)", - capture=_capture_dqdv_split_charge, - ), - IcaGoldenCase( - suite="ica_dqdv_split_discharge", - description="dqdv(cell, split=True) discharge frame (long)", - capture=_capture_dqdv_split_discharge, - ), - IcaGoldenCase( - suite="ica_dqdv_wide", - description="dqdv(cell, tidy=False) wide MultiIndex frame (flattened)", - capture=_capture_dqdv_wide, - ), -) - - -def prepare_ica_for_golden(frame: pd.DataFrame) -> pd.DataFrame: - """Return a stable float-typed frame for golden parity.""" - out = frame.copy().reset_index(drop=True) - for col in out.columns: - if pd.api.types.is_numeric_dtype(out[col]): - out[col] = out[col].astype("float64") - return out[sorted(out.columns)] - - -def ica_metrics(case: IcaGoldenCase, frame: pd.DataFrame) -> dict[str, Any]: - """Scalar summary alongside the frame, so a diff says *what* moved.""" - numeric = frame.select_dtypes("number") - return { - "columns": list(frame.columns), - "description": case.description, - "n_columns": int(len(frame.columns)), - "n_rows": int(len(frame)), - # Rounded to keep the committed file readable; the parquet frame - # carries the exact values. Note that rounding alone does not make - # these comparable across platforms - a total that lands on a rounding - # boundary still flips - so the test compares them with a tolerance. - "sums": { - col: round(float(np.nansum(numeric[col].to_numpy())), 6) - for col in numeric.columns - }, - "source": RES_FILE.relative_to(REPO_ROOT).as_posix(), - "suite": case.suite, - } - - -def capture_ica_case(case: IcaGoldenCase) -> tuple[pd.DataFrame, dict[str, Any]]: - """Run one ICA case and return (frame, metrics).""" - cell = load_golden_cell() - result = case.capture(cell) - if not isinstance(result, pd.DataFrame): - raise TypeError( - f"{case.suite} produced {type(result)!r}; ICA goldens require a DataFrame" - ) - frame = prepare_ica_for_golden(result) - return frame, ica_metrics(case, frame) - - -def assert_ica_matches_golden(actual: pd.DataFrame, expected: pd.DataFrame) -> None: - """Compare ICA frames value-for-value.""" - from pandas.testing import assert_frame_equal - - actual = prepare_ica_for_golden(actual) - expected = prepare_ica_for_golden(expected) - assert list(actual.columns) == list(expected.columns) - # pandas' 1e-5 default, and that is not a shrug. The goldens are recorded - # on one machine and re-run on another, and scipy's interpolation and - # filtering differ between platforms by **1e-7 to 5e-7 relative** here - # (measured: 258.2250118395604 on Windows against 258.2250426021722 on - # Linux CI). Anything tighter than ~1e-6 tests the BLAS, not cellpy. - # - # 1e-5 still leaves 20-100x headroom over that noise, and the drift these - # oracles exist to catch - a reordered pipeline stage, a changed default, - # a dropped smoothing pass - moves values by percent. - assert_frame_equal(actual, expected, check_dtype=False) diff --git a/tests/test_ica.py b/tests/test_ica.py index 9a1c35a9..9e24f93c 100644 --- a/tests/test_ica.py +++ b/tests/test_ica.py @@ -1,225 +1,78 @@ import logging -import pandas as pd import pytest from cellpy import log -from cellpy.exceptions import NullData from cellpy.utils import ica -# import warnings -# warnings.simplefilter("ignore", FutureWarning) -# warnings.simplefilter("error", FutureWarning) - -# note! FutureWarning in converter.pre_process_data() -# scipy/signal/_savitzky_golay.py:175: in _fit_edge -# FutureWarning: Using a non-tuple sequence for multidimensional -# indexing is deprecated - log.setup_logging(default_level=logging.DEBUG, testing=True) - -@pytest.fixture -def converter(dataset): - q, v = dataset.get_ccap(1, as_frame=False) - o = ica.Converter() - o.set_data(q, v) - return o - - -def test_ica_converter(dataset): - # warnings.simplefilter("error", FutureWarning) - list_of_cycles = dataset.get_cycle_numbers() - number_of_cycles = len(list_of_cycles) - logging.debug(f"you have {number_of_cycles} cycles") - cycle = 5 - logging.debug(f"looking at cycle {cycle}") - capacity, voltage = dataset.get_ccap(cycle, as_frame=False) - converter = ica.Converter() - converter.set_data(capacity, voltage) - converter.inspect_data() - converter.pre_process_data() - converter.increment_data() - converter.post_process_data() +# The 1.x ICA surface (Converter, dqdv_np, dqdv_cycles, dqdv(split=/tidy=/cycle=/ +# label_direction=), the duplicate `dq` column) was removed in 2.1 (E2, #714). +# What remains is the specced modern API: dqdv / dvdq / to_wide + value/index +# bounds. The numerical oracle for transform_half_cycle lives in test_ica_api.py. -@pytest.mark.xfail(raises=NullData) -def test_none_data(): - # 1.x got a TypeError out of `assert len(capacity) == len(voltage)` on - # None. The core checks for missing data first, so "no data" now raises - # the exception cellpy has for exactly that (#566). - ica.dqdv_np(None, None) - - -@pytest.mark.xfail(raises=NullData) -def test_short_data(): - ica.dqdv_np(pd.Series(), pd.Series()) - - -@pytest.mark.parametrize("cycle", [1, 2, 3, 4, 5, 10]) -def test_ica_dqdv(dataset, cycle): - capacity, voltage = dataset.get_ccap(cycle, as_frame=False) - ica.dqdv_np(voltage, capacity) +# --- small pure helpers (public since 1.x) ----------------------------------- def test_ica_value_bounds_simple(): - x = [1, 2, 3, 4] - m1, m2 = ica.value_bounds(x) + m1, m2 = ica.value_bounds([1, 2, 3, 4]) assert m1 == 1 assert m2 == 4 def test_ica_value_bounds(dataset): capacity, voltage = dataset.get_ccap(5, mode="gravimetric", as_frame=False) - c = ica.value_bounds(capacity) - v = ica.value_bounds(voltage) - assert c == pytest.approx((0.001106868, 1535.303235807), 0.0001) - assert v == pytest.approx((0.15119725465774536, 1.0001134872436523), 0.0001) + assert ica.value_bounds(capacity) == pytest.approx( + (0.001106868, 1535.303235807), 0.0001 + ) + assert ica.value_bounds(voltage) == pytest.approx( + (0.15119725465774536, 1.0001134872436523), 0.0001 + ) def test_ica_index_bounds(dataset): capacity, voltage = dataset.get_ccap(5, as_frame=False) - c = ica.index_bounds(capacity) - v = ica.index_bounds(voltage) - assert c == pytest.approx((0.001106868, 1535.303235807), 0.0001) - assert v == pytest.approx((0.15119725465774536, 1.0001134872436523), 0.0001) - - -def test_ica_dqdv_cycles(dataset): - cycles = dataset.get_cap( - method="forth-and-forth", - categorical_column=True, - label_cycle_number=True, - insert_nan=False, + assert ica.index_bounds(capacity) == pytest.approx( + (0.001106868, 1535.303235807), 0.0001 + ) + assert ica.index_bounds(voltage) == pytest.approx( + (0.15119725465774536, 1.0001134872436523), 0.0001 ) - dQdV = ica.dqdv_cycles(cycles) - - -def test_ica_str(dataset): - o = ica.Converter() - print(o) - - -def test_set_data(dataset): - q, v = dataset.get_ccap(1, as_frame=False) - data = pd.concat([q, v], axis=1) - o = ica.Converter() - o.set_data(data, capacity_label=q.name, voltage_label=v.name) - - -def test_inspect_data(converter): - converter.inspect_data(err_est=True, diff_est=True) - converter.pre_process_data() - converter.increment_data() - converter.post_process_data() - v = converter.voltage_processed - q = converter.incremental_capacity - assert len(v) == len(q) - assert len(v) > 1 - - -def test_pre_process_data_smoothing(converter): - converter.inspect_data() - converter.pre_smoothing = True - converter.pre_process_data() - converter.increment_data() - converter.post_process_data() - v = converter.voltage_processed - q = converter.incremental_capacity - assert len(v) == len(q) - assert len(v) > 1 - - -def test_increment_hist_method(converter): - converter.inspect_data() - converter.pre_smoothing = True - converter.increment_method = "hist" - converter.pre_process_data() - converter.increment_data() - print(len(converter.incremental_capacity)) - print(len(converter.voltage_processed)) - - # import matplotlib.pyplot as plt - # plt.plot(converter.voltage_processed, converter.incremental_capacity) - # plt.plot(converter.voltage_processed, converter.incremental_capacity) - # plt.show() - - -def test_increment_data_smoothing(converter): - converter.inspect_data() - converter.pre_process_data() - converter.smoothing = True - converter.increment_data() - converter.post_process_data() - v = converter.voltage_processed - q = converter.incremental_capacity - assert len(v) == len(q) - assert len(v) > 1 - - -def test_dqdv_split(dataset): - df_ica_charge, df_ica_discharge = ica.dqdv(dataset, split=True, cycle=2) - assert df_ica_charge.size == 300 - assert df_ica_discharge.size == 300 - df_ica_charge, df_ica_discharge = ica.dqdv(dataset, split=True) - assert df_ica_charge.size == 5400 - assert df_ica_discharge.size == 5400 - assert "voltage" in df_ica_charge.columns - assert "cycle" in df_ica_charge.columns - assert "dq" in df_ica_charge.columns - - -def test_dqdv_one_cycle_tidy(dataset): - # `cycle=` is the deprecated spelling of `cycles=`, so this returns the - # 2.0 specced frame. It has one row fewer than the 1.x frame per cycle: - # 1.x inserted a NaN "splitter" row between the two half-cycles so that a - # naive line plot would not join them, which the direction column makes - # unnecessary. - df_ica = ica.dqdv(dataset, cycle=2) - assert list(df_ica.columns) == [ - "cycle", - "direction", - "voltage", - "capacity", - "dqdv", - "dq", - ] + + +# --- the specced modern frame ------------------------------------------------ + + +_SPECCED_COLS = ["cycle", "direction", "voltage", "capacity", "dqdv"] + + +def test_dqdv_one_cycle_specced(dataset): + df_ica = ica.dqdv(dataset, cycles=2) + assert list(df_ica.columns) == _SPECCED_COLS assert not df_ica["voltage"].isna().any() assert len(df_ica) == 759 -def test_dqdv_multi_cycles_tidy(dataset): +def test_dqdv_multi_cycles_specced(dataset): df_ica = ica.dqdv(dataset) - assert "voltage" in df_ica.columns - assert "cycle" in df_ica.columns - assert "dqdv" in df_ica.columns + assert list(df_ica.columns) == _SPECCED_COLS # 8889 rows in 1.x, less one splitter row for each of the 18 cycles. assert len(df_ica) == 8889 - 18 -def test_dqdv_multi_cycles_wide(dataset): - df_ica = ica.dqdv(dataset, tidy=False) +def test_dqdv_to_wide(dataset): + wide = ica.to_wide(ica.dqdv(dataset)) cycles_available = set(dataset.get_cycle_numbers()) - cycles_processed = set(df_ica.columns.get_level_values(0)) + cycles_processed = { + int(str(c).split()[0]) for c in wide.columns.get_level_values(0) + } assert cycles_available.issuperset(cycles_processed) - assert "voltage" in df_ica.columns.get_level_values(1) - assert "dq" in df_ica.columns.get_level_values(1) - assert df_ica.size == 39744 - - -# TODO - aulv: this test should be un-commented when hist-method -# is implemented -# def test_increment_data_hist(converter): -# converter.inspect_data() -# converter.pre_process_data() -# converter.smoothing = True -# converter.increment_method = "hist" -# converter.increment_data() -# converter.post_process_data() -# v = converter.voltage_processed -# q = converter.incremental_capacity -# assert len(v) == len(q) -# assert len(v) > 1 - - -# missing test: fixed_range in post_process_data + assert "voltage" in wide.columns.get_level_values(1) + assert "dqdv" in wide.columns.get_level_values(1) + + +def test_deprecated_ica_surface_is_gone(): + for name in ("Converter", "dqdv_cycle", "dqdv_cycles", "dqdv_np"): + assert not hasattr(ica, name), name diff --git a/tests/test_ica_api.py b/tests/test_ica_api.py index 53966569..92e2276d 100644 --- a/tests/test_ica_api.py +++ b/tests/test_ica_api.py @@ -189,7 +189,6 @@ def test_dqdv_frame_has_the_specced_columns(dataset): "voltage", "capacity", "dqdv", - "dq", ] @@ -198,13 +197,6 @@ def test_dvdq_frame_has_the_specced_columns(dataset): assert list(frame.columns) == ["cycle", "direction", "capacity", "voltage", "dvdq"] -def test_the_deprecated_dq_column_duplicates_dqdv(dataset): - frame = ica.dqdv(dataset, cycles=1) - pd.testing.assert_series_equal( - frame["dq"], frame["dqdv"], check_names=False - ) - - def test_frame_attrs_record_the_recipe(dataset): frame = ica.dqdv(dataset, cycles=1, voltage_fwhm=0.02) assert frame.attrs["derivative"] == "dqdv" diff --git a/tests/test_ica_goldens.py b/tests/test_ica_goldens.py deleted file mode 100644 index 0118b64f..00000000 --- a/tests/test_ica_goldens.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Golden regression tests for the ICA (dQ/dV) pipeline (#566). - -These are the numeric net under the ICA redesign: the recipe is a chain of -order-sensitive operations (interpolate V(q) → optional pre-smooth → invert to -q(V) → optional smooth → differentiate → optional gaussian → normalize), and -reordering any two of them still yields a plausibly-shaped curve. Only values -catch that. -""" - -from __future__ import annotations - -import json - -import pandas as pd -import pytest - -from tests.ica_golden_support import ( - ICA_GOLDEN_CASES, - IcaGoldenCase, - assert_ica_matches_golden, - capture_ica_case, -) - - -def _ica_case_id(case: IcaGoldenCase) -> str: - return case.suite - - -@pytest.mark.essential -@pytest.mark.parametrize("case", ICA_GOLDEN_CASES, ids=_ica_case_id) -def test_ica_output_matches_golden(case: IcaGoldenCase): - reason = case.skip_reason() - if reason: - pytest.skip(reason) - - expected = pd.read_parquet(case.golden_dir / "ica.parquet") - actual, _ = capture_ica_case(case) - assert_ica_matches_golden(actual, expected) - - -@pytest.mark.essential -@pytest.mark.parametrize("case", ICA_GOLDEN_CASES, ids=_ica_case_id) -def test_ica_metrics_match_golden(case: IcaGoldenCase): - reason = case.skip_reason() - if reason: - pytest.skip(reason) - - expected = json.loads((case.golden_dir / "metrics.json").read_text(encoding="utf-8")) - _, actual = capture_ica_case(case) - - assert actual["n_rows"] == expected["n_rows"] - assert actual["n_columns"] == expected["n_columns"] - assert actual["columns"] == expected["columns"] - - # Compared with a tolerance, not exactly: this first failed on Linux with - # 122.614595 against a golden of 122.614594 recorded on Windows. Rounding - # the stored value does not help - a total that lands near the rounding - # boundary still flips. See the note in ica_golden_support for the measured - # cross-platform spread. - assert set(actual["sums"]) == set(expected["sums"]) - for column, value in expected["sums"].items(): - assert actual["sums"][column] == pytest.approx(value, rel=1e-5), column diff --git a/tests/test_ica_plot_prepare.py b/tests/test_ica_plot_prepare.py index 5bb1c491..9cb5b8b7 100644 --- a/tests/test_ica_plot_prepare.py +++ b/tests/test_ica_plot_prepare.py @@ -40,7 +40,7 @@ def test_prepare_ica_returns_ica_spec(cell): assert not frame.empty assert spec.extras.get("kind") == "ica" assert ICA_COLS.dqdv in frame.columns - assert ICA_COLS.legacy_dqdv not in frame.columns + assert "dq" not in frame.columns # legacy dq column removed in 2.1 (#714) assert {CHARGE, DISCHARGE} <= set(frame[ICA_COLS.direction].unique())