Source code for cellpy.utils.plotutils

# -*- coding: utf-8 -*-
"""
Utilities for helping to plot cellpy-data.
"""

import collections
import dataclasses
import importlib
import itertools
import logging
from multiprocessing import Process
import pickle as pkl
import pprint
from typing import Any, Callable, Optional, Union
import warnings
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

from cellpy.parameters.internal_settings import (
    get_headers_journal,
    get_headers_normal,
    get_headers_step_table,
    get_headers_summary,
)
from cellpy.utils import helpers

plotly_available = importlib.util.find_spec("plotly") is not None
seaborn_available = importlib.util.find_spec("seaborn") is not None

# Refactoring work in progress:
# - homogenize plotting tools (plotutils, batchutils and collectors) to
#   remove the need to change code in many files when changing plotting settings and tools
# - utilize the prms system to set plotting settings
# - make standardized plot templates and looks

# including this to mimic the behaviour of collectors:
supported_backends = []
if plotly_available:
    supported_backends.append("plotly")
if seaborn_available:
    supported_backends.append("seaborn")

# logger = logging.getLogger(__name__)
logging.captureWarnings(True)

# from collectors - template:

PLOTLY_BASE_TEMPLATE = "plotly"
IMAGE_TO_FILE_TIMEOUT = 30


[docs] def notebook_docstring_printer(func, default_show_docstring=False): """ Decorator that prints the function's docstring when called from a notebook environment. This decorator checks if the function is being called from a Jupyter notebook or IPython environment and prints the function's docstring if it is. Args: func: The function to decorate Returns: The decorated function """ def wrapper(*args, **kwargs): # Check if we're in a notebook environment show_docstring = kwargs.pop("show_docstring", default_show_docstring) if show_docstring: try: # Check for IPython/Jupyter environment import IPython ipython = IPython.get_ipython() if ipython is not None and hasattr(ipython, "kernel"): # We're in a notebook environment if func.__doc__: print(f"{func.__name__} docstring:") print("-" * (len(func.__name__) + 12)) print(func.__doc__) print("-" * (len(func.__name__) + 12)) else: print(f"No docstring found for {func.__name__}") except (ImportError, AttributeError): # Not in a notebook environment, continue silently pass # Call the original function return func(*args, **kwargs) # Preserve the original function's metadata wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ wrapper.__module__ = func.__module__ return wrapper
# from collectors - tools for loading and saving plots:
[docs] def load_figure(filename, backend=None): """Load figure from file.""" filename = Path(filename) if backend is None: suffix = filename.suffix if suffix in [".pkl", ".pickle"]: backend = "matplotlib" elif suffix in [".json", ".plotly", ".jsn"]: backend = "plotly" else: backend = "plotly" if backend == "plotly": return load_plotly_figure(filename) elif backend == "seaborn": return load_matplotlib_figure(filename) elif backend == "matplotlib": return load_matplotlib_figure(filename) else: print(f"WARNING: {backend=} is not supported at the moment") return None
[docs] def save_matplotlib_figure(fig, filename): pkl.dump(fig, open(filename, "wb"))
[docs] def make_matplotlib_manager(fig): """Create a new manager for a matplotlib figure.""" # create a dummy figure and use its # manager to display "fig" ; based on https://stackoverflow.com/a/54579616/8508004 dummy = plt.figure() new_manager = dummy.canvas.manager new_manager.canvas.figure = fig fig.set_canvas(new_manager.canvas) return fig
[docs] def load_matplotlib_figure(filename, create_new_manager=False): fig = pkl.load(open(filename, "rb")) if create_new_manager: fig = make_matplotlib_manager(fig) return fig
[docs] def load_plotly_figure(filename): """Load plotly figure from file.""" # TODO: create a decorator for this: if not plotly_available: print("Plotly not available") return None import plotly.io as pio try: fig = pio.read_json(filename) except Exception as e: print("Could not load figure from json file") print(e) return None return fig
def _image_exporter_plotly(figure, filename, timeout=IMAGE_TO_FILE_TIMEOUT, **kwargs): p = Process( target=figure.write_image, args=(filename,), name="save_plotly_image_to_file", kwargs=kwargs, ) p.start() p.join(timeout=timeout) p.terminate() if p.exitcode is None: print(f"Oops, {p} timeouts! Could not save {filename}") if p.exitcode == 0: print(f" - saved image file: {filename}")
[docs] @notebook_docstring_printer def save_image_files( figure: Any, name: str = "my_figure", scale: float = 3.0, dpi: int = 300, backend: str = "plotly", formats: Optional[list] = None, ): """Save to image files (png, svg, json/pickle). Notes: This method requires ``kaleido`` for the plotly backend. Notes: Exporting to json is only applicable for the plotly backend. Args: figure (fig-object): The figure to save. name (pathlib.Path or str): The path of the file (without extension). scale (float): The scale of the image. dpi (int): The dpi of the image. backend (str): The backend to use (plotly or seaborn/matplotlib). formats (list): The formats to save (default: ["png", "svg", "json", "pickle"]). """ filename = Path(name) filename_png = filename.with_suffix(".png") filename_svg = filename.with_suffix(".svg") filename_json = filename.with_suffix(".json") filename_pickle = filename.with_suffix(".pickle") if formats is None: formats = ["png", "svg", "json", "pickle"] if backend == "plotly": if "png" in formats: _image_exporter_plotly(figure, filename_png, scale=scale) if "svg" in formats: _image_exporter_plotly(figure, filename_svg) if "json" in formats: figure.write_json(filename_json) print(f" - saved plotly json file: {filename_json}") elif backend in ["seaborn", "matplotlib"]: if "png" in formats: figure.savefig(filename_png, dpi=dpi) print(f" - saved png file: {filename_png}") if "svg" in formats: figure.savefig(filename_svg) print(f" - saved svg file: {filename_svg}") if "pickle" in formats: save_matplotlib_figure(figure, filename_pickle) print(f" - pickled to file: {filename_pickle}") else: print(f"TODO: implement saving {filename_png}") print(f"TODO: implement saving {filename_svg}") print(f"TODO: implement saving {filename_json}")
def _make_plotly_template(name="axis"): if not plotly_available: print("Plotly not available") return None import plotly.graph_objects as go import plotly.io as pio tick_label_width = 6 title_font_size = 22 title_font_family = "Arial" axis_font_size = 16 axis_standoff = 15 linecolor = "rgb(36,36,36)" t = go.layout.Template( layout=dict( font_family=title_font_family, title=dict( font_size=title_font_size, x=0, xref="paper", ), xaxis=dict( linecolor=linecolor, mirror=True, showline=True, zeroline=False, title=dict( standoff=axis_standoff, font_size=axis_font_size, ), ), yaxis=dict( linecolor=linecolor, mirror=True, showline=True, zeroline=False, tickformat=f"{tick_label_width}", title=dict( standoff=axis_standoff, font_size=axis_font_size, ), ), ) ) pio.templates[name] = t # from batch_plotters: def _plotly_remove_markers(trace): trace.update(marker=None, mode="lines") return trace def _plotly_legend_replacer(trace, df, group_legends=True): name = trace.name parts = name.split(",") if len(parts) == 2: group = int(parts[0]) subgroup = int(parts[1]) else: print( "Have not implemented replacing legend labels that are not on the form a,b yet." ) print(f"legend label: {name}") return trace cell_label = df.loc[ (df[_hdr_journal.group] == group) & (df[_hdr_journal.sub_group] == subgroup), "cell" ].values[0] if group_legends: trace.update( name=cell_label, legendgroup=group, hovertemplate=f"{cell_label}<br>{trace.hovertemplate}", ) else: trace.update( name=cell_label, legendgroup=cell_label, hovertemplate=f"{cell_label}<br>{trace.hovertemplate}", ) # original: SYMBOL_DICT = { "all": [ "s", "o", "v", "^", "<", ">", "D", "p", "*", "1", "2", ".", ",", "3", "4", "8", "p", "d", "h", "H", "+", "x", "X", "|", "_", ], "simple": ["s", "o", "v", "^", "<", ">", "*", "d"], } COLOR_DICT = { "classic": ["b", "g", "r", "c", "m", "y", "k"], "grayscale": ["0.00", "0.40", "0.60", "0.70"], "bmh": [ "#348ABD", "#A60628", "#7A68A6", "#467821", "#D55E00", "#CC79A7", "#56B4E9", "#009E73", "#F0E442", "#0072B2", ], "dark_background": [ "#8dd3c7", "#feffb3", "#bfbbd9", "#fa8174", "#81b1d2", "#fdb462", "#b3de69", "#bc82bd", "#ccebc4", "#ffed6f", ], "ggplot": [ "#E24A33", "#348ABD", "#988ED5", "#777777", "#FBC15E", "#8EBA42", "#FFB5B8", ], "fivethirtyeight": ["#30a2da", "#fc4f30", "#e5ae38", "#6d904f", "#8b8b8b"], "seaborn-colorblind": [ "#0072B2", "#009E73", "#D55E00", "#CC79A7", "#F0E442", "#56B4E9", ], "seaborn-deep": ["#4C72B0", "#55A868", "#C44E52", "#8172B2", "#CCB974", "#64B5CD"], "seaborn-bright": [ "#003FFF", "#03ED3A", "#E8000B", "#8A2BE2", "#FFC400", "#00D7FF", ], "seaborn-muted": ["#4878CF", "#6ACC65", "#D65F5F", "#B47CC7", "#C4AD66", "#77BEDB"], "seaborn-pastel": [ "#92C6FF", "#97F0AA", "#FF9F9A", "#D0BBFF", "#FFFEA3", "#B0E0E6", ], "seaborn-dark-palette": [ "#001C7F", "#017517", "#8C0900", "#7600A1", "#B8860B", "#006374", ], } PLOTLY_BLANK_LABEL = { "font": {}, "showarrow": False, "text": "", "x": 1.1, "xanchor": "center", "xref": "paper", "y": 1.0, "yanchor": "bottom", "yref": "paper", } def _plotly_label_dict(text, x, y): d = PLOTLY_BLANK_LABEL.copy() d["text"] = text d["x"] = x d["y"] = y return d _hdr_summary = get_headers_summary() _hdr_raw = get_headers_normal() _hdr_steps = get_headers_step_table() _hdr_journal = get_headers_journal()
[docs] def set_plotly_template(template_name=None, **kwargs): """Set the default plotly template.""" if not plotly_available: return None import plotly.io as pio try: if template_name is None: name = create_plotly_default_template(**kwargs) pio.templates.default = f"{PLOTLY_BASE_TEMPLATE}+{name}" else: pio.templates.default = template_name except Exception as e: logging.debug(f"Could not set plotly template: {e}") pio.templates.default = PLOTLY_BASE_TEMPLATE
[docs] def create_plotly_default_template( name="all_axis", font_color="#455A64", marker_edge_on=False, marker_size=12, marker_edge_color="white", marker_width=None, opacity=0.8, ): # ValueError: Invalid property specified for object of type plotly.graph_objs.layout.XAxis: 'titlefont' if not plotly_available: return None import plotly.graph_objects as go import plotly.io as pio axis_color = "rgb(36,36,36)" grid_color = "white" axis_font = "Arial Black" axis_font_size = 16 # axis_standoff = 15 axis_standoff = None tick_label_width = 6 title_font_size = 22 title_font_family = "Arial Black, Helvetica, Sans-serif" title_font_color = font_color marker = dict( size=marker_size, ) line = dict() if marker_edge_on: if marker_width is None: if marker_size is not None: marker_width = marker_size / 6 else: marker_width = 0.5 marker["line"] = dict( width=marker_width, color=marker_edge_color, ) axis = dict( linecolor=axis_color, mirror=True, showline=True, gridcolor=grid_color, zeroline=False, tickformat=f"{tick_label_width}", title_font_family=axis_font, title=dict( standoff=axis_standoff, font_size=axis_font_size, font_color=font_color, ), ) title = dict( font_family=title_font_family, font_size=title_font_size, font_color=title_font_color, x=0, xref="paper", ) data = dict( scatter=[go.Scatter(marker=marker, line=line, opacity=opacity)], ) pio.templates[name] = go.layout.Template( layout=dict(title=title, xaxis=axis, yaxis=axis), data=data ) return name
[docs] def create_colormarkerlist_for_journal( journal, symbol_label="all", color_style_label="seaborn-colorblind" ): """Fetch lists with color names and marker types of correct length for a journal. Args: journal: cellpy journal symbol_label: sub-set of markers to use color_style_label: cmap to use for colors Returns: colors (list), markers (list) """ logging.debug("symbol_label: " + symbol_label) logging.debug("color_style_label: " + color_style_label) groups = journal.pages[_hdr_journal.group].unique() sub_groups = journal.pages[_hdr_journal.subgroup].unique() return create_colormarkerlist(groups, sub_groups, symbol_label, color_style_label)
[docs] def create_colormarkerlist( groups, sub_groups, symbol_label="all", color_style_label="seaborn-colorblind" ): """Fetch lists with color names and marker types of correct length. Args: groups: list of group numbers (used to generate the list of colors) sub_groups: list of sub-group numbers (used to generate the list of markers). symbol_label: sub-set of markers to use color_style_label: cmap to use for colors Returns: colors (list), markers (list) """ symbol_list = SYMBOL_DICT[symbol_label] color_list = COLOR_DICT[color_style_label] # checking that we have enough colors and symbols (if not, then use cycler (e.g. reset)) color_cycler = itertools.cycle(color_list) symbol_cycler = itertools.cycle(symbol_list) _color_list = [] _symbol_list = [] for i in groups: _color_list.append(next(color_cycler)) for i in sub_groups: _symbol_list.append(next(symbol_cycler)) return _color_list, _symbol_list
def _get_capacity_unit(c, mode="gravimetric", seperator="/"): specific_selector = { "gravimetric": f"{c.cellpy_units.charge}{seperator}{c.cellpy_units.specific_gravimetric}", "areal": f"{c.cellpy_units.charge}{seperator}{c.cellpy_units.specific_areal}", "volumetric": f"{c.cellpy_units.charge}{seperator}{c.cellpy_units.specific_volumetric}", "absolute": f"{c.cellpy_units.charge}", } return specific_selector.get(mode, "-") # Per-row y-axis labels for predefined ``y`` sets that route a different # quantity onto row 0 (efficiency plots, *_with_rate plots). The "_plotly" # and "_seaborn" variants differ only in the line-break character (HTML # ``<br>`` vs ``\n``) so each builder gets a string it can render natively. def _plotly_top_row_label(y: str) -> Optional[str]: if y.endswith("_efficiency"): return "Coulombic Efficiency" if y.endswith("_with_rate"): return "C-rate (1/h)" return None def _seaborn_top_row_label(y: str) -> Optional[str]: if y.endswith("_efficiency"): return "Coulombic\nEfficiency (%)" if y.endswith("_with_rate"): return "C-rate\n(1/h)" return None def _has_special_top_row(y: str) -> bool: """True for y-sets whose row 0 holds a different quantity than the other rows (so plotters should disable shared y-axis and pick a per-row y-label).""" return y.endswith("_efficiency") or y.endswith("_with_rate") # TODO: consistent parameter names (e.g. y_range vs ylim) between summary_plot, plot_cycles, raw_plot, cycle_info_plot and batchutils # TODO: consistent function names (raw_plot vs plot_raw etc)
[docs] @dataclasses.dataclass class SummaryPlotConfig: """Configuration dataclass for summary_plot parameters. Encapsulates all parameters for summary_plot to improve maintainability and enable easier refactoring. """ # Core parameters x: Optional[str] = None y: str = "capacities_gravimetric_coulombic_efficiency" # Plot dimensions height: Optional[int] = None width: int = 900 # Plot styling markers: bool = True title: Optional[str] = None # Axis ranges x_range: Optional[list] = None y_range: Optional[list] = None ce_range: Optional[list] = None norm_range: Optional[list] = None cv_share_range: Optional[list] = None # Plot layout split: bool = True hover_columns: Optional[list] = None auto_convert_legend_labels: bool = True interactive: bool = True share_y: bool = False rangeslider: bool = False # Return options return_data: bool = False verbose: bool = False # Backend-specific plotly_template: Optional[str] = None seaborn_palette: str = "deep" seaborn_style: str = "dark" # Formation cycles formation_cycles: int = 3 show_formation: bool = True show_legend: bool = True x_axis_domain_formation_fraction: float = 0.2 column_separator: float = 0.01 # Fullcell standard specific reset_losses: bool = True link_capacity_scales: bool = False fullcell_standard_normalization_type: str = "max" fullcell_standard_normalization_factor: Optional[float] = None fullcell_standard_normalization_scaler: float = 1.0 fullcell_standard_normalization_cycle_numbers: Optional[list[int]] = None # Seaborn hooks seaborn_line_hooks: Optional[list[tuple[str, list, dict]]] = None # Summary filtering / rate handling (issue #363) filters: Optional[dict] = None nominal_capacity: Optional[float] = None rate_filter_columns: Optional[Union[str, tuple, list]] = None # Additional kwargs (stored as dict) additional_kwargs: dict = dataclasses.field(default_factory=dict) def __post_init__(self) -> None: # Mirror the legacy normalisation: a non-positive ``formation_cycles`` # (including ``False`` / ``0`` / ``None``) means there is no formation # block to draw, so ``show_formation`` must be False regardless of # how the caller set it. Without this, ``_mark_formation_cycles`` # returns the ``slice(None, None)`` sentinel while ``show_formation`` # stays True, and ``_configure_formation_axes`` then evaluates # ``~slice(...)`` which raises ``TypeError``. See issue #366. if self.formation_cycles is None: self.formation_cycles = 0 self.formation_cycles = int(self.formation_cycles) if self.formation_cycles < 1: self.show_formation = False def __str__(self) -> str: variables = vars(self) outputs = ["SummaryPlotConfig:"] outputs.extend([f"{k}: {pprint.pformat(v)}" for k, v in variables.items()]) return "\n".join(outputs) def __repr__(self) -> str: return self.__str__()
[docs] @classmethod def from_kwargs(cls, **kwargs) -> "SummaryPlotConfig": """Create SummaryPlotConfig from keyword arguments. Extracts known parameters and stores remaining kwargs in additional_kwargs. """ # Get known parameter names from dataclass fields (excluding additional_kwargs) known_params = { f.name for f in dataclasses.fields(cls) if f.name != "additional_kwargs" } # Separate known params from additional kwargs config_params = {k: v for k, v in kwargs.items() if k in known_params} additional_kwargs = {k: v for k, v in kwargs.items() if k not in known_params} # Create config with known params config_params["additional_kwargs"] = additional_kwargs return cls(**config_params)
[docs] def to_kwargs(self) -> dict: """Convert config back to kwargs dict for passing to legacy function.""" kwargs = dataclasses.asdict(self) # Extract additional_kwargs and merge them additional = kwargs.pop("additional_kwargs", {}) # Remove None values to match legacy function behavior kwargs = { k: v for k, v in kwargs.items() if v is not None or k in [ "x", "height", "title", "plotly_template", "fullcell_standard_normalization_factor", ] } kwargs.update(additional) return kwargs
[docs] class SummaryPlotInfo: x_cols: Optional[tuple] = None y_cols: Optional[dict] = None x_trans: Optional[dict] = None y_trans: Optional[dict] = None x_axis_labels: Optional[dict] = None y_axis_label: Optional[dict] = None def __init__(self, c: Any): """Initialize SummaryPlotInfo. This class contains information about the summary plot. It is used to store the information about the columns and labels. Args: c: cellpy object """ self._create_col_info(c) self._create_label_dict(c) def __str__(self) -> str: variables = vars(self) outputs = ["SummaryPlotInfo:"] outputs.extend([f"{k}: {pprint.pformat(v)}" for k, v in variables.items()]) return "\n".join(outputs) def __repr__(self) -> str: return self.__str__() def _create_label_dict(self, c: Any) -> tuple[dict, dict]: """Create label dictionary for summary plots. Args: c: cellpy object Returns: x_axis_labels (dict), y_axis_label (dict) """ hdr = c.headers_summary x_axis_labels = { hdr.cycle_index: "Cycle Number", hdr.data_point: "Point", hdr.test_time: f"Test Time ({c.cellpy_units.time})", hdr.datetime: "Date", hdr.normalized_cycle_index: "Equivalent Full Cycle", # hdr.normalized_cycle_index: "Normalized Cycle Number", } _cap_gravimetric_label = ( f"Capacity ({c.cellpy_units.charge}/{c.cellpy_units.specific_gravimetric})" ) _cap_areal_label = ( f"Capacity ({c.cellpy_units.charge}/{c.cellpy_units.specific_areal})" ) _cap_absolute_label = f"Capacity ({c.cellpy_units.charge})" _cap_label = f"Capacity ({c.data.raw_units.charge})" y_axis_label = { "voltages": f"Voltage ({c.cellpy_units.voltage})", "capacities_gravimetric": _cap_gravimetric_label, "capacities_areal": _cap_areal_label, "capacities_absolute": _cap_absolute_label, "capacities": _cap_label, "capacities_gravimetric_split_constant_voltage": _cap_gravimetric_label, "capacities_areal_split_constant_voltage": _cap_areal_label, "capacities_absolute_split_constant_voltage": _cap_absolute_label, "capacities_gravimetric_coulombic_efficiency": _cap_gravimetric_label, "capacities_areal_coulombic_efficiency": _cap_areal_label, "capacities_absolute_coulombic_efficiency": _cap_absolute_label, "capacities_gravimetric_with_rate": _cap_gravimetric_label, "capacities_areal_with_rate": _cap_areal_label, "capacities_absolute_with_rate": _cap_absolute_label, "fullcell_standard_gravimetric": _cap_gravimetric_label, "fullcell_standard_areal": _cap_areal_label, "fullcell_standard_absolute": _cap_absolute_label, } self.x_axis_labels = x_axis_labels self.y_axis_label = y_axis_label
[docs] @staticmethod def normalize_col( x: np.ndarray, normalization_factor: Optional[float] = None, normalization_type: str = "max", normalization_scaler: float = 1.0, normalization_indexes: list[int] = [1], ) -> np.ndarray: """Normalize a column. Args: x: column to normalize normalization_factor: normalization factor normalization_type: normalization type normalization_scaler: normalization scaler normalization_indexes: indexes to use for normalization Normalization types: - divide: divide by normalization factor and then multiply by normalization scaler - shift-divide: shift by normalization factor and then divide by normalization factor and then multiply by normalization scaler - multiply: multiply by normalization factor and normalization scaler - area: divide by area (integrated using trapezoid rule) and then multiply by normalization scaler - max: divide by maximum value and then multiply by normalization scaler - on-max: divide by maximum value over normalization factor and then multiply by normalization scaler - on-cycles: divide by mean value of the cycles in normalization_indexes and then multiply by normalization scaler - false: no normalization is done Returns: normalized column """ # These normalization types do NOT require a normalization factor: if normalization_type == "area": with warnings.catch_warnings(): warnings.simplefilter("ignore") area = np.trapzoid(x, dx=1) return (x / area) * normalization_scaler elif normalization_type == "max": with warnings.catch_warnings(): warnings.simplefilter("ignore") x_max = x.max() return (x / x_max) * normalization_scaler elif normalization_type == "on-cycles": x_on_cycles = [] for cycle in normalization_indexes: try: x_on_cycles.append(x[cycle]) except KeyError: logging.warning(f"Cycle number {cycle} not found in data") if len(x_on_cycles) == 0: raise ValueError( f"No cycle numbers found in data: {normalization_indexes}" ) x_on_cycles_mean = np.mean(x_on_cycles) return (x / x_on_cycles_mean) * normalization_scaler elif normalization_type == "false": return x # These normalization types require a normalization factor: if normalization_factor is None: raise ValueError( f"Normalization factor is required for this normalization type: {normalization_type}" ) elif normalization_type == "divide": return (x / normalization_factor) * normalization_scaler elif normalization_type == "shift-divide": return ( (normalization_factor - x) / normalization_factor ) * normalization_scaler elif normalization_type == "multiply": return (x * normalization_factor) * normalization_scaler elif normalization_type == "on-max": with warnings.catch_warnings(): warnings.simplefilter("ignore") x_max = x.max() return (x / x_max / normalization_factor) * normalization_scaler else: raise ValueError(f"Invalid normalization type: {normalization_type}")
def _create_col_info(self, c: Any) -> tuple[tuple, dict, dict, dict]: """Create column information for summary plots. This function is called by summary_plot together with create_label_dict. The two functions need to be updated together. Not optimal. So feel free to refactor it. Args: c: cellpy object Returns: x_columns (tuple), y_cols (dict), x_transformations (dict), y_transformations (dict) """ hdr = c.headers_summary _cap_cols = [hdr.charge_capacity_raw, hdr.discharge_capacity_raw] _capacities_gravimetric = [col + "_gravimetric" for col in _cap_cols] _capacities_gravimetric_split = ( _capacities_gravimetric + [col + "_cv" for col in _capacities_gravimetric] + [col + "_non_cv" for col in _capacities_gravimetric] ) _capacities_areal = [col + "_areal" for col in _cap_cols] _capacities_areal_split = ( _capacities_areal + [col + "_cv" for col in _capacities_areal] + [col + "_non_cv" for col in _capacities_areal] ) _capacities_absolute = [col + "_absolute" for col in _cap_cols] _capacities_absolute_split = ( _capacities_absolute + [col + "_cv" for col in _capacities_absolute] + [col + "_non_cv" for col in _capacities_absolute] ) x_columns = ( [ hdr.cycle_index, hdr.data_point, hdr.test_time, hdr.datetime, hdr.normalized_cycle_index, ], ) y_cols = dict( voltages=[hdr.end_voltage_charge, hdr.end_voltage_discharge], capacities_gravimetric=_capacities_gravimetric, capacities_areal=_capacities_areal, capacities_absolute=_capacities_absolute, capacities=_cap_cols, capacities_gravimetric_split_constant_voltage=_capacities_gravimetric_split, capacities_areal_split_constant_voltage=_capacities_areal_split, capacities_gravimetric_coulombic_efficiency=_capacities_gravimetric + [hdr.coulombic_efficiency], capacities_areal_coulombic_efficiency=_capacities_areal + [hdr.coulombic_efficiency], capacities_absolute_coulombic_efficiency=_capacities_absolute + [hdr.coulombic_efficiency], capacities_gravimetric_with_rate=_capacities_gravimetric + [hdr.charge_c_rate, hdr.discharge_c_rate], capacities_areal_with_rate=_capacities_areal + [hdr.charge_c_rate, hdr.discharge_c_rate], capacities_absolute_with_rate=_capacities_absolute + [hdr.charge_c_rate, hdr.discharge_c_rate], fullcell_standard_cumloss_gravimetric=[ hdr.charge_capacity + "_gravimetric" + "_cv", hdr.cumulated_discharge_capacity_loss + "_gravimetric", hdr.discharge_capacity + "_gravimetric", hdr.coulombic_efficiency, ], fullcell_standard_cumloss_areal=[ hdr.charge_capacity + "_areal" + "_cv", hdr.cumulated_discharge_capacity_loss + "_areal", hdr.discharge_capacity + "_areal", hdr.coulombic_efficiency, ], fullcell_standard_cumloss_absolute=[ hdr.charge_capacity + "_absolute" + "_cv", hdr.cumulated_discharge_capacity_loss + "_absolute", hdr.discharge_capacity + "_absolute", hdr.coulombic_efficiency, ], fullcell_standard_gravimetric=[ hdr.charge_capacity + "_gravimetric" + "_cv", hdr.discharge_capacity + "_gravimetric", "mod_01_" + hdr.discharge_capacity + "_gravimetric", hdr.coulombic_efficiency, ], fullcell_standard_areal=[ hdr.charge_capacity + "_areal" + "_cv", hdr.discharge_capacity + "_areal", "mod_01_" + hdr.discharge_capacity + "_areal", hdr.coulombic_efficiency, ], fullcell_standard_absolute=[ hdr.charge_capacity + "_absolute" + "_cv", hdr.discharge_capacity + "_absolute", "mod_01_" + hdr.discharge_capacity + "_absolute", hdr.coulombic_efficiency, ], fullcell_standard_dev=[ hdr.charge_capacity + "_gravimetric" + "_cv", hdr.discharge_capacity + "_gravimetric", hdr.coulombic_efficiency, "mod_01_" + hdr.discharge_capacity + "_gravimetric", ], ) _normalize_col = self.normalize_col x_transformations = dict() # transformation info on the form: column_name: {(row_number, new_column_name): transformation_function} y_transformations: dict[str, dict[tuple[int, str], dict[str, Callable]]] = dict( fullcell_standard_cumloss_gravimetric={ hdr.cumulated_discharge_capacity_loss + "_gravimetric": { ( 2, hdr.cumulated_discharge_capacity_loss + "_gravimetric", ): _normalize_col }, }, fullcell_standard_cumloss_areal={ hdr.cumulated_discharge_capacity_loss + "_areal": { ( 2, hdr.cumulated_discharge_capacity_loss + "_areal", ): _normalize_col }, }, fullcell_standard_cumloss_absolute={ hdr.cumulated_discharge_capacity_loss + "_absolute": { ( 2, hdr.cumulated_discharge_capacity_loss + "_absolute", ): _normalize_col }, }, fullcell_standard_gravimetric={ "mod_01_" + hdr.discharge_capacity + "_gravimetric": { ( 2, hdr.discharge_capacity + "_retention" + "_gravimetric", ): _normalize_col }, }, fullcell_standard_areal={ "mod_01_" + hdr.discharge_capacity + "_areal": { ( 2, hdr.discharge_capacity + "_retention" + "_areal", ): _normalize_col }, }, fullcell_standard_absolute={ "mod_01_" + hdr.discharge_capacity + "_absolute": { ( 2, hdr.discharge_capacity + "_retention" + "_absolute", ): _normalize_col }, }, fullcell_standard_dev={ "mod_01_" + hdr.discharge_capacity + "_gravimetric": { ( 2, hdr.discharge_capacity + "_retention" + "_gravimetric", ): _normalize_col }, }, ) self.x_cols = x_columns self.y_cols = y_cols self.x_trans = x_transformations self.y_trans = y_transformations
[docs] class SummaryPlotDataPreparer: """Handles data collection and transformation for summary plots. This class extracts the data preparation logic from summary_plot_legacy to improve maintainability and testability. """ def __init__(self): self.y_header = "value" self.color = "variable" self.row = "row" self.col_id = "cycle_type"
[docs] def prepare_data( self, c: Any, config: SummaryPlotConfig, plot_info: SummaryPlotInfo, ) -> dict: """Prepare data for plotting. Args: c: cellpy object config: SummaryPlotConfig with all parameters summary_plot_info: SummaryPlotInfo containing information about pre-defined columns and labels Returns: Dictionary with keys: - data: prepared DataFrame - number_of_rows: number of rows for subplot layout - x_label: x-axis label - y_label: y-axis label - max_cycle: maximum cycle number - min_cycle: minimum cycle number - max_val_normalized_col: max value for normalized columns - formation_cycle_selector: boolean selector for formation cycles """ x = config.x if config.x is not None else "cycle_index" y = config.y number_of_rows = 1 max_val_normalized_col = 0.0 if config.hover_columns and ( y.startswith("fullcell_standard_") or y.endswith("_split_constant_voltage") ): logging.warning( "summary_plot: hover_columns is currently only supported for " "standard plot types; ignoring for y=%r", y, ) # Prepare data based on plot type if y.startswith("fullcell_standard_"): s, number_of_rows = self._prepare_fullcell_standard_data( c, x, y, plot_info.y_cols, plot_info.y_trans, config ) max_val_normalized_col = ( s.loc[s["variable"].str.contains("retention"), "value"].max() if len(s.loc[s["variable"].str.contains("retention")]) > 0 else 0.0 ) elif y.endswith("_split_constant_voltage"): s, number_of_rows = self._prepare_cv_split_data( c, x, y, plot_info.y_cols, config ) else: s, number_of_rows = self._prepare_standard_data( c, x, y, plot_info.y_cols, config ) # Calculate cycle ranges max_cycle = s[x].max() min_cycle = s[x].min() # Get labels x_label = plot_info.x_axis_labels.get(x, x) if y in plot_info.y_axis_label: y_label = plot_info.y_axis_label.get(y, y) else: y_label = y.replace("_", " ").title() # Mark formation cycles formation_cycle_selector = self._mark_formation_cycles( s, x, config.formation_cycles, self.col_id ) return { "data": s, "number_of_rows": number_of_rows, "x_label": x_label, "y_label": y_label, "max_cycle": max_cycle, "min_cycle": min_cycle, "max_val_normalized_col": max_val_normalized_col, "formation_cycle_selector": formation_cycle_selector, }
def _prepare_fullcell_standard_data( self, c, x, y, y_cols, y_trans, config ) -> tuple: """Prepare data for fullcell_standard plots.""" # The figure has 4 rows: coulombic efficiency, capacity, capacity retention, and CV capacity number_of_rows = 4 column_set = y_cols.get(y, y) summary = self._preprocess_summary(c, c.data.summary, config) if summary.index.name == x: summary = summary.reset_index(drop=False) # Get CV-only summary summary_only_cv = c.make_summary( selector_type="only-cv", create_copy=True ).data.summary if summary_only_cv.index.name == x: summary_only_cv = summary_only_cv.reset_index(drop=False) # Merge summaries s = summary.merge(summary_only_cv, on=x, how="outer", suffixes=("", "_cv")) s = s.reset_index(drop=True) s = s.melt(x) s = s.loc[s.variable.isin(column_set)] s[self.row] = 1 # default row for capacity # Set row numbers using regex patterns s.loc[s["variable"].str.contains(r"_efficiency$"), self.row] = ( 0 # coulombic efficiency ) s.loc[s["variable"].str.contains(r"cumulated.*loss"), self.row] = ( 2 # cumulated loss ) s.loc[s["variable"].str.startswith(r"mod_01_"), self.row] = ( 2 # capacity retention ) s.loc[s["variable"].str.contains(r"_cv$"), self.row] = 3 # cv data # Reset losses if requested if config.reset_losses: logging.debug("Resetting losses") first_values = ( s[s["variable"].str.contains(r"cumulated.*loss")] .groupby("variable")["value"] .transform("first") ) mask = s["variable"].str.contains(r"cumulated.*loss") s.loc[mask, "value"] = s.loc[mask, "value"] - first_values # Apply normalization if requested if config.fullcell_standard_normalization_type is not False: logging.debug("Applying normalization") s, max_val_normalized_col = self._apply_normalization( s, y, y_trans, config, self.row ) return s, number_of_rows def _prepare_cv_split_data(self, c, x, y, y_cols, config) -> tuple: """Prepare data for CV split plots.""" import warnings if y.startswith("capacities_gravimetric"): cap_type = "capacities_gravimetric" elif y.startswith("capacities_areal"): cap_type = "capacities_areal" elif y.startswith("capacities_absolute"): cap_type = "capacities_absolute" else: raise ValueError(f"Unknown capacity type for CV split: {y}") column_set = y_cols[cap_type] # Use partition_summary_cv_steps function with warnings.catch_warnings(): warnings.simplefilter("ignore") s = partition_summary_cv_steps( c, x, column_set, config.split, self.color, self.y_header ) number_of_rows = 3 if config.split else 1 return s, number_of_rows def _prepare_standard_data(self, c, x, y, y_cols, config) -> tuple: """Prepare data for standard plots.""" column_set = y_cols.get(y, y) if isinstance(column_set, str): column_set = [column_set] summary = self._preprocess_summary(c, c.data.summary, config) summary = summary.reset_index() # Check if requested columns exist in summary # For absolute capacities, fall back to base columns if _absolute columns don't exist available_columns = set(summary.columns) requested_columns = set(column_set) missing_columns = requested_columns - available_columns if missing_columns and y == "capacities_absolute": # For absolute capacities, if _absolute columns don't exist, use base columns hdr = c.headers_summary base_columns = [hdr.charge_capacity_raw, hdr.discharge_capacity_raw] # Check if base columns exist if all(col in available_columns for col in base_columns): column_set = base_columns else: # If base columns also don't exist, keep original column_set # This will result in empty DataFrame, which will be handled downstream pass elif missing_columns: # For other capacity types, if columns are missing, keep original column_set # This will result in empty DataFrame, which will be handled downstream pass hover_cols = list(config.hover_columns or []) if hover_cols: missing = [h for h in hover_cols if h not in summary.columns] if missing: logging.warning( "summary_plot: dropping unknown hover_columns %s " "(available: %s)", missing, sorted(summary.columns), ) hover_cols = [h for h in hover_cols if h in summary.columns] # Avoid duplicating x and value columns in id_vars hover_cols = [h for h in hover_cols if h != x and h not in column_set] id_vars = [x, *hover_cols] s = summary.melt(id_vars=id_vars) s = s.loc[s.variable.isin(column_set)] s = s.reset_index(drop=True) # Check if we have any data after filtering if len(s) == 0: raise ValueError( f"No data found for plot type '{y}'. " f"Requested columns: {column_set}. " f"Available columns in summary: {list(available_columns)}" ) s[self.row] = 1 number_of_rows = 1 if config.split: if y.endswith("_efficiency"): s[self.row] = 1 s.loc[s["variable"].str.contains("efficiency"), self.row] = 0 number_of_rows = 2 elif y.endswith("_with_rate"): hdr = c.headers_summary rate_cols = {hdr.charge_c_rate, hdr.discharge_c_rate} s[self.row] = 1 s.loc[s["variable"].isin(rate_cols), self.row] = 0 number_of_rows = 2 return s, number_of_rows def _apply_normalization(self, s, y, y_trans, config, row_col) -> tuple: """Apply normalization transformations to data.""" import re from collections.abc import Iterable max_val_normalized_col = 0.0 normalization_factor = config.fullcell_standard_normalization_factor normalization_type = config.fullcell_standard_normalization_type normalization_cycle_numbers = ( config.fullcell_standard_normalization_cycle_numbers ) # TODO: check if this is really needed!! # Determine normalization factor if not provided if normalization_factor is None: logging.debug( f"No normalization factor provided for {y}, using {normalization_type}" ) if y.startswith("fullcell_standard_cumloss_") and normalization_type != "max": logging.debug("only allowing for 'max' for cumloss plots") normalization_type = "max" if normalization_type in ["on-cycles", "on-cycle"]: if normalization_cycle_numbers is None: raise ValueError( "Normalization cycle numbers are required for on-cycles normalization" ) if isinstance(normalization_cycle_numbers, Iterable): cycle_numbers = [cycle - 1 for cycle in normalization_cycle_numbers] else: cycle_numbers = [normalization_cycle_numbers - 1] normalization_cycle_numbers = cycle_numbers trans_kwargs = dict( normalization_factor=normalization_factor, normalization_type=normalization_type, normalization_scaler=config.fullcell_standard_normalization_scaler, normalization_indexes=normalization_cycle_numbers, ) # Transform the data max_row_val = s[row_col].max() for col, trans_dict in y_trans.get(y, {}).items(): for (new_row_val, new_col), trans in trans_dict.items(): if new_col in s["variable"].values: # Transforming on existing column s.loc[s["variable"] == col, "value"] = trans( s.loc[s["variable"] == col, "value"].values, **trans_kwargs ) else: # Creating new column old_col = col if new_row_val is not None: row_val = new_row_val else: row_val = s.loc[s["variable"] == col, row_col] if not row_val.empty: row_val = row_val.values[0] else: max_row_val += 1 row_val = max_row_val if old_col.startswith("mod_"): old_col = re.sub(r"^mod_\d{2}_", "", old_col) new_col_frame_section = s.loc[s["variable"] == old_col].copy() new_col_frame_section["variable"] = new_col new_col_frame_section[row_col] = row_val transformed_values = trans( new_col_frame_section["value"].values, **trans_kwargs ) new_col_frame_section["value"] = transformed_values s = pd.concat([s, new_col_frame_section], ignore_index=True) s = s.reset_index(drop=True) s = s.sort_values(by=[row_col, "variable"]) max_val_normalized_col = s.loc[s["variable"] == new_col, "value"].max() return s, max_val_normalized_col def _mark_formation_cycles(self, s, x, formation_cycles, col_id): """Mark formation cycles in the data.""" formation_cycle_selector = slice(None, None) if formation_cycles > 0: formation_cycle_selector = s[x] <= formation_cycles s[col_id] = "standard" s.loc[formation_cycle_selector, col_id] = "formation" return formation_cycle_selector @staticmethod def _preprocess_summary(c: Any, summary: pd.DataFrame, config) -> pd.DataFrame: """Apply optional rate-rescaling and row filtering to a summary copy. Two opt-in steps, both no-ops when their config field is ``None``: * ``config.nominal_capacity`` rescales the existing ``charge_c_rate`` / ``discharge_c_rate`` columns to use a new nominal capacity instead of the one set on the cell. The rescale factor is ``c.data.nom_cap / nominal_capacity``: since ``rate = current / nom_cap``, the rate columns are multiplied by ``old_nom_cap / new_nom_cap``. * ``config.filters`` is forwarded to :func:`cellpy.filters.filter_summary`. The default ``rate_filter_columns`` resolves to both rate columns from ``c.headers_summary`` (charge AND discharge). Operates on a copy; the caller's ``summary`` argument is not mutated. """ out = summary.copy() if summary is not None else summary if config.nominal_capacity is not None: hdr = c.headers_summary old_nom_cap = getattr(c.data, "nom_cap", None) if old_nom_cap in (None, 0): logging.warning( "summary_plot: nominal_capacity override requested but " "cell.data.nom_cap is %r; skipping rate rescale.", old_nom_cap, ) else: scale = float(old_nom_cap) / float(config.nominal_capacity) for col in (hdr.charge_c_rate, hdr.discharge_c_rate): if col in out.columns: out[col] = out[col] * scale logging.debug( "summary_plot: rescaled rate columns by %.6g " "(old nom_cap=%s, new=%s)", scale, old_nom_cap, config.nominal_capacity, ) if config.filters: from cellpy.filters import filter_summary hdr = c.headers_summary filter_kwargs = dict(config.filters) if ( "rate" in filter_kwargs and "rate_columns" not in filter_kwargs ): if config.rate_filter_columns is not None: filter_kwargs["rate_columns"] = config.rate_filter_columns else: filter_kwargs["rate_columns"] = ( hdr.charge_c_rate, hdr.discharge_c_rate, ) before = len(out) out = filter_summary(out, **filter_kwargs) logging.debug( "summary_plot: filters %s reduced rows %d -> %d", filter_kwargs, before, len(out), ) return out
[docs] class PlotlyPlotBuilder: """Handles Plotly-specific plotting logic for summary plots. This class extracts the Plotly plotting logic from summary_plot_legacy to improve maintainability and testability. """ def __init__(self): self.y_header = "value" self.color = "variable" self.row = "row" self.col_id = "cycle_type"
[docs] def build_plot( self, data: pd.DataFrame, prepared_data_info: dict, config: SummaryPlotConfig, additional_kwargs: dict, c: Any, ) -> Any: """Build Plotly figure from prepared data. Args: data: Prepared DataFrame from SummaryPlotDataPreparer prepared_data_info: Dictionary with metadata from data preparer config: SummaryPlotConfig with all parameters additional_kwargs: Additional kwargs for plotly (from legacy function) c: cellpy object (needed for some label generation) Returns: Plotly figure object """ import plotly.express as px # Extract plotly-specific parameters from additional_kwargs smart_link = additional_kwargs.pop("smart_link", True) show_y_labels_on_right_pane = additional_kwargs.pop( "show_y_labels_on_right_pane", False ) plotly_row_ratios = additional_kwargs.pop( "fullcell_standard_row_height_ratios", [0.3, 0.6, 0.9] ) plotly_row_space = additional_kwargs.pop("fullcell_standard_row_space", 0.02) # Extract plotly_* parameters for update_traces plotly_update_traces = {} for k in list(additional_kwargs.keys()): if k.startswith("plotly_"): plotly_update_traces[k.replace("plotly_", "")] = additional_kwargs.pop( k ) # Set default title if not provided title = config.title if title is None: title = f"Summary <b>{c.cell_name}</b>" x = config.x if config.x is not None else "cycle_index" y = config.y number_of_rows = prepared_data_info["number_of_rows"] x_label = prepared_data_info["x_label"] y_label = prepared_data_info["y_label"] max_cycle = prepared_data_info["max_cycle"] min_cycle = prepared_data_info["min_cycle"] max_val_normalized_col = prepared_data_info["max_val_normalized_col"] formation_cycle_selector = prepared_data_info["formation_cycle_selector"] # Prepare plotly kwargs plotly_kwargs = { "color": self.color, "height": config.height, "markers": config.markers, "title": title, "width": config.width, } # Add facet_row if split if config.split and self.row in data.columns: plotly_kwargs["facet_row"] = self.row # Add hover columns if they survived data preparation if config.hover_columns: present = [h for h in config.hover_columns if h in data.columns] if present: plotly_kwargs["hover_data"] = present # Set default height if not provided if plotly_kwargs.get("height") is None: if y.startswith("fullcell_standard_"): plotly_kwargs["height"] = 800 elif config.split and number_of_rows > 1: plotly_kwargs["height"] = 800 else: plotly_kwargs["height"] = 200 + 200 * number_of_rows # Set plotly template set_plotly_template(config.plotly_template) # Add facet_col for formation cycles if config.show_formation and self.col_id in data.columns: plotly_kwargs["facet_col"] = self.col_id # Create base figure fig = px.line( data, x=x, y=self.y_header, **plotly_kwargs, labels={ x: x_label, self.y_header: y_label, }, **additional_kwargs, ) # Update traces if plotly_update_traces: fig.update_traces(**plotly_update_traces) # Hide legend if requested if not config.show_legend: fig.update_layout(showlegend=False) # Apply y_range if provided if config.y_range is not None: fig.update_layout(yaxis=dict(range=config.y_range)) # Configure formation cycles and subplot layouts if config.show_formation: self._configure_formation_axes( fig, data, x, config, number_of_rows, max_cycle, min_cycle, formation_cycle_selector, show_y_labels_on_right_pane, y, max_val_normalized_col, plotly_row_ratios, plotly_row_space, c, ) else: # Configure without formation cycles self._configure_no_formation_axes( fig, config, y, number_of_rows, max_val_normalized_col, plotly_row_ratios, plotly_row_space, c, ) # Apply x_range if provided if config.x_range is not None: if not config.show_formation: fig.update_layout(xaxis=dict(range=config.x_range)) # Handle split and share_y if config.split: if config.show_formation: if not config.share_y and not smart_link: fig.update_yaxes(matches=None) elif not config.share_y: fig.update_yaxes(matches=None) # Add rangeslider if requested if config.rangeslider: if config.show_formation: logging.critical( "Can not add rangeslider when showing formation cycles" ) else: fig.update_layout(xaxis_rangeslider_visible=True) # Auto-convert legend labels if config.auto_convert_legend_labels and config.show_legend: self._convert_legend_labels(fig) return fig
def _auto_range(self, fig: Any, axis_name_1: str, axis_name_2: str) -> list: """Calculate auto range for two y-axes (only works for plotly).""" from copy import deepcopy min_y = np.inf max_y = -np.inf full_axis_name_1 = axis_name_1.replace("y", "yaxis") full_axis_name_2 = axis_name_2.replace("y", "yaxis") _range_1 = getattr(fig.layout, f"{full_axis_name_1}_range", None) _range_2 = getattr(fig.layout, f"{full_axis_name_2}_range", None) if _range_1 is None: _range_1 = [np.inf, -np.inf] if _range_2 is None: _range_2 = [np.inf, -np.inf] _range = [min(_range_1[0], _range_2[0]), max(_range_1[1], _range_2[1])] for i, t in enumerate(deepcopy(fig.data)): if t.yaxis in [axis_name_1, axis_name_2]: y = deepcopy(t.y) try: y = np.array(y, dtype=float) min_y = np.ma.masked_invalid(y).min() max_y = np.ma.masked_invalid(y).max() except Exception as e: warnings.warn( f"Could not calculate min and max for y-axis (data set {i}): {e}" ) _range = [min(_range[0], min_y), max(_range[1], max_y)] _range = [0.95 * _range[0], 1.05 * _range[1]] return _range def _configure_formation_axes( self, fig, data, x, config, number_of_rows, max_cycle, min_cycle, formation_cycle_selector, show_y_labels_on_right_pane, y, max_val_normalized_col, plotly_row_ratios, plotly_row_space, c, ): """Configure axes when showing formation cycles.""" formation_header = '<span style="color:red">Formation</span>' x_axis_domain_formation = [ 0.0, config.x_axis_domain_formation_fraction - config.column_separator / 2, ] x_axis_domain_rest = [ config.x_axis_domain_formation_fraction + config.column_separator / 2, 0.95, ] max_cycle_formation = data.loc[formation_cycle_selector, x].max() min_cycle_rest = data.loc[~formation_cycle_selector, x].min() if x == _hdr_summary.normalized_cycle_index: dd = 0.1 else: dd = 0.4 x_axis_range_formation = [min_cycle - dd, max_cycle_formation + dd] x_axis_range_rest = [min_cycle_rest - dd, max_cycle + dd] if config.x_range is not None: x_axis_range_rest = [ x_axis_range_rest[0], min(config.x_range[1], x_axis_range_rest[1]), ] eff_lim = config.ce_range if number_of_rows == 1: self._configure_formation_1_row( fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, ) elif number_of_rows == 2: self._configure_formation_2_rows( fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, config.y_range, eff_lim, y, ) elif number_of_rows == 3: self._configure_formation_3_rows( fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, ) elif number_of_rows == 4: self._configure_formation_4_rows( fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, y, max_val_normalized_col, config, plotly_row_ratios, plotly_row_space, c, ) else: raise NotImplementedError("Not implemented for more than four rows") def _configure_formation_1_row( self, fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, ): """Configure 1-row plot with formation cycles.""" fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, xaxis=dict(range=x_axis_range_formation), xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None, ), ) # Clear all existing annotations (including automatic facet column headers) # to prevent both vertical and horizontal formation headers from appearing # For number_of_rows == 1, Plotly creates 3 annotations (2 facet columns + 1 row label) # We need to replace all of them with only the 2 we want # Use _plotly_label_dict to create proper annotation with all required properties annotations = [ _plotly_label_dict(formation_header, 0.08, 1.02), PLOTLY_BLANK_LABEL, ] fig.layout["annotations"] = annotations fig.update_layout( yaxis2=dict(matches="y", showticklabels=show_y_labels_on_right_pane), ) def _configure_formation_2_rows( self, fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, y_range, eff_lim, y, ): """Configure 2-row plot with formation cycles.""" fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) _top_label = _plotly_top_row_label(y) if _top_label is not None: fig.update_layout( yaxis3={ "title": dict(text=_top_label), "domain": [0.7, 1.0], }, yaxis1=dict(domain=[0.0, 0.65]), yaxis2=dict(domain=[0.0, 0.65]), yaxis4=dict(domain=[0.70, 1.0]), ) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = y_range or self._auto_range(fig, "y", "y2") range_2 = eff_lim or self._auto_range(fig, "y3", "y4") fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict( matches="y2", range=range_1, ), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict( matches="y4", range=range_2, ), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 3 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations def _configure_formation_3_rows( self, fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, ): """Configure 3-row plot with formation cycles.""" fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = self._auto_range(fig, "y", "y2") range_2 = self._auto_range(fig, "y3", "y4") range_3 = self._auto_range(fig, "y5", "y6") fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis5=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis6=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict(matches="y2", range=range_1), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict(matches="y4", range=range_2), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), yaxis5=dict(matches="y6", range=range_3), yaxis6=dict( matches="y5", showticklabels=show_y_labels_on_right_pane, range=range_3, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 5 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations def _configure_formation_4_rows( self, fig, x_axis_domain_formation, x_axis_range_formation, x_axis_range_rest, x_axis_domain_rest, formation_header, show_y_labels_on_right_pane, y, max_val_normalized_col, config, plotly_row_ratios, plotly_row_space, c, ): """Configure 4-row plot with formation cycles.""" fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = self._auto_range(fig, "y", "y2") if ( y.startswith("fullcell_standard_") and config.fullcell_standard_normalization_type is not False ): range_2 = [ 0.0, max( max_val_normalized_col, config.fullcell_standard_normalization_scaler, ), ] range_2 = config.norm_range or range_2 else: range_2 = self._auto_range(fig, "y3", "y4") range_3 = self._auto_range(fig, "y5", "y6") range_4 = self._auto_range(fig, "y7", "y8") if y.startswith("fullcell_standard_"): range_4 = config.ce_range or range_4 range_3 = config.y_range or range_3 range_1 = config.cv_share_range or range_1 fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis5=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis6=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis7=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis8=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict(matches="y2", range=range_1), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict(matches="y4", range=range_2), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), yaxis5=dict(matches="y6", range=range_3), yaxis6=dict( matches="y5", showticklabels=show_y_labels_on_right_pane, range=range_3, ), yaxis7=dict(matches="y8", range=range_4), yaxis8=dict( matches="y7", showticklabels=show_y_labels_on_right_pane, range=range_4, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 7 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations if y.startswith("fullcell_standard_"): self._configure_fullcell_standard_domains( fig, config, plotly_row_ratios, plotly_row_space, c, y, ) def _configure_fullcell_standard_domains( self, fig, config, plotly_row_ratios, plotly_row_space, c, y, ): """Configure domain layout for fullcell_standard plots.""" ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0 capacity_domain_start, capacity_domain_end = ( plotly_row_ratios[1], plotly_row_ratios[2] - plotly_row_space, ) loss_domain_start, loss_domain_end = ( plotly_row_ratios[0], plotly_row_ratios[1] - plotly_row_space, ) cv_domain_start, cv_domain_end = ( 0.0, plotly_row_ratios[0] - plotly_row_space, ) # Format y-axis labels with HTML for proper alignment mode = y.split("_")[-1] capacity_unit = _get_capacity_unit(c, mode=mode) ce_label = "Coulombic<br>Efficiency (%)" capacity_label = f"Capacity<br>({capacity_unit})" if ( config.fullcell_standard_normalization_type and config.fullcell_standard_normalization_factor is not None ): _norm_label = f"[{config.fullcell_standard_normalization_scaler:.1f}/{config.fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity<br>Retention (norm.)<br>{_norm_label}" else: loss_label = f"Capacity<br>Retention ({capacity_unit})" cv_label = f"CV Capacity<br>({capacity_unit})" fig.update_layout( yaxis8={"domain": [ce_domain_start, ce_domain_end]}, yaxis7={ "title": dict(text=ce_label), "domain": [ce_domain_start, ce_domain_end], }, yaxis6={"domain": [capacity_domain_start, capacity_domain_end]}, yaxis5={ "title": dict(text=capacity_label), "domain": [capacity_domain_start, capacity_domain_end], }, yaxis4={"domain": [loss_domain_start, loss_domain_end]}, yaxis3={ "title": dict(text=loss_label), "domain": [loss_domain_start, loss_domain_end], }, yaxis2={"domain": [cv_domain_start, cv_domain_end]}, yaxis1={ "title": dict(text=cv_label), "domain": [cv_domain_start, cv_domain_end], }, ) if config.show_formation: fig.update_layout( xaxis1={"title": dict(text="")}, ) if config.x_axis_domain_formation_fraction < 0.1: fig.update_layout( xaxis1={"showticklabels": False}, ) if config.link_capacity_scales: fig.update_layout( yaxis={"matches": "y2"}, yaxis2={"matches": "y3"}, yaxis3={"matches": "y4"}, yaxis4={"matches": "y5"}, yaxis5={"matches": "y6"}, ) def _configure_no_formation_axes( self, fig, config, y, number_of_rows, max_val_normalized_col, plotly_row_ratios, plotly_row_space, c, ): """Configure axes when not showing formation cycles.""" eff_lim = config.ce_range _top_label = _plotly_top_row_label(y) if _top_label is not None: fig.update_layout( yaxis=dict(domain=[0.0, 0.65]), yaxis2={ "title": dict(text=_top_label), "domain": [0.7, 1.0], }, ) if y.startswith("fullcell_standard_"): range_1 = eff_lim or self._auto_range(fig, "y4", "y4") range_2 = config.y_range or self._auto_range(fig, "y3", "y3") range_3 = self._auto_range(fig, "y2", "y2") if config.fullcell_standard_normalization_type is not False: range_3 = [ 0.0, max( max_val_normalized_col, config.fullcell_standard_normalization_scaler, ), ] range_3 = config.norm_range or range_3 range_4 = config.cv_share_range or self._auto_range(fig, "y", "y") fig.layout["annotations"] = 4 * [PLOTLY_BLANK_LABEL] ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0 capacity_domain_start, capacity_domain_end = ( plotly_row_ratios[1], plotly_row_ratios[2] - plotly_row_space, ) loss_domain_start, loss_domain_end = ( plotly_row_ratios[0], plotly_row_ratios[1] - plotly_row_space, ) cv_domain_start, cv_domain_end = ( 0.0, plotly_row_ratios[0] - plotly_row_space, ) # Format y-axis labels with HTML for proper alignment capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1]) ce_label = "Coulombic<br>Efficiency (%)" capacity_label = f"Capacity<br>({capacity_unit})" if ( config.fullcell_standard_normalization_type and config.fullcell_standard_normalization_factor is not None ): _norm_label = f"[{config.fullcell_standard_normalization_scaler:.1f}/{config.fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity<br>Retention (norm.)<br>{_norm_label}" else: loss_label = f"Capacity<br>Retention ({capacity_unit})" cv_label = f"CV Capacity<br>({capacity_unit})" fig.update_layout( yaxis4={ "title": dict(text=ce_label), "domain": [ce_domain_start, ce_domain_end], "matches": None, "range": range_1, }, yaxis3={ "title": dict(text=capacity_label), "domain": [capacity_domain_start, capacity_domain_end], "matches": None, "range": range_2, }, yaxis2={ "title": dict(text=loss_label), "domain": [loss_domain_start, loss_domain_end], "matches": None, "range": range_3, }, yaxis={ "title": dict(text=cv_label), "domain": [cv_domain_start, cv_domain_end], "matches": None, "range": range_4, }, ) def _convert_legend_labels(self, fig): """Convert legend labels to nicer format.""" for trace in fig.data: name = trace.name name = name.replace("_", " ").title() name = name.replace("Gravimetric", "Grav.") name = name.replace("Cv", "(CV)") name = name.replace("Non (CV)", "(without CV)") hover_template = trace.hovertemplate if hover_template: statements = [] for statement in hover_template.split("<br>"): if "=" in statement: variable, value = statement.split("=", 1) if value.startswith("%{y}"): variable = name statement = "=".join((variable, value)) statements.append(statement) hover_template = "<br>".join(statements) trace.update(name=name, hovertemplate=hover_template)
[docs] class SeabornPlotBuilder: """Handles Seaborn-specific plotting logic for summary plots. This class extracts the Seaborn plotting logic from summary_plot_legacy to improve maintainability and testability. """ def __init__(self): self.y_header = "value" self.color = "variable" self.row = "row" self.col_id = "cycle_type"
[docs] def build_plot( self, data: pd.DataFrame, prepared_data_info: dict, config: SummaryPlotConfig, additional_kwargs: dict, c: Any, ) -> Any: """Build Seaborn/Matplotlib figure from prepared data. Args: data: Prepared DataFrame from SummaryPlotDataPreparer prepared_data_info: Dictionary with metadata from data preparer config: SummaryPlotConfig with all parameters additional_kwargs: Additional kwargs for seaborn (from legacy function) c: cellpy object (needed for some label generation) Returns: Matplotlib figure object """ if not seaborn_available: warnings.warn( "seaborn not available, returning only the data so that you can plot it yourself instead" ) return data import seaborn as sns import matplotlib.pyplot as plt # Extract seaborn-specific parameters seaborn_facecolor = additional_kwargs.pop("seaborn_facecolor", "#EAEAF2") seaborn_edgecolor = additional_kwargs.pop("seaborn_edgecolor", "black") seaborn_style_dict_default = { "axes.facecolor": seaborn_facecolor, "axes.edgecolor": seaborn_edgecolor, } seaborn_style_dict = additional_kwargs.pop( "seaborn_style_dict", seaborn_style_dict_default ) seaborn_marker_size = additional_kwargs.pop("seaborn_marker_size", 7) xlim_formation = additional_kwargs.pop( "xlim_formation", (0.6, config.formation_cycles + 0.4) ) # Set default title if not provided title = config.title if title is None: title = f"Summary {c.cell_name}" x = config.x if config.x is not None else "cycle_index" y = config.y number_of_rows = prepared_data_info["number_of_rows"] x_label = prepared_data_info["x_label"] y_label = prepared_data_info["y_label"] max_cycle = prepared_data_info["max_cycle"] max_val_normalized_col = prepared_data_info["max_val_normalized_col"] # Set up seaborn sns.set_style(config.seaborn_style, seaborn_style_dict) sns.set_palette(config.seaborn_palette) sns.set_context(additional_kwargs.pop("seaborn_context", "notebook")) # Configure facet and gridspec kwargs facet_kws = dict(despine=False, sharex=False, sharey=False) gridspec_kws = dict(hspace=0.07) # Configure columns for formation cycles col_id = None if config.show_formation and self.col_id in data.columns: additional_kwargs["col"] = self.col_id number_of_cols = 2 col_id = self.col_id gridspec_kws["width_ratios"] = additional_kwargs.pop("width_ratios", [1, 6]) gridspec_kws["wspace"] = additional_kwargs.pop("wspace", 0.02) else: number_of_cols = 1 # Configure rows # Note: number_of_rows from prepared_data_info is the expected number, # but we need to verify it matches the actual data row_id = None if not config.split: number_of_rows = 1 logging.debug(f"split=False, setting number_of_rows=1") else: row_id = self.row if self.row in data.columns: additional_kwargs["row"] = self.row actual_number_of_rows = data[self.row].nunique() # Use the actual number from data, but log if it differs from expected if actual_number_of_rows != number_of_rows: logging.warning( f"Number of rows mismatch: expected {number_of_rows} from data preparer, " f"but data has {actual_number_of_rows} unique row values. Using {actual_number_of_rows}." ) number_of_rows = actual_number_of_rows logging.debug( f"split=True, row column '{self.row}' found, number_of_rows={number_of_rows}" ) else: # If split=True but row column doesn't exist, fall back to 1 row logging.warning( f"split=True but row column '{self.row}' not found in data. " f"Expected {number_of_rows} rows but falling back to 1 row." ) number_of_rows = 1 logging.debug( f"split=True but row column '{self.row}' not found, setting number_of_rows=1" ) # Calculate plot properties plot_type = ( "fullcell_standard" if y.startswith("fullcell_standard_") else "default" ) seaborn_plot_height, seaborn_plot_aspect = ( self._calculate_seaborn_plot_properties( number_of_rows, number_of_cols, plot_type ) ) seaborn_plot_height = additional_kwargs.pop( "seaborn_plot_height", seaborn_plot_height ) seaborn_plot_aspect = additional_kwargs.pop( "seaborn_plot_aspect", seaborn_plot_aspect ) # Calculate axis limits eff_lim = config.ce_range if eff_lim is None: eff_lim = self._calculate_efficiency_limits(data) x_range = config.x_range if x_range is None: cycle_range = max_cycle - config.formation_cycles if cycle_range <= 0: cycle_range = 10 # arbitrary value x_range = ( config.formation_cycles + 1 - 0.02 * abs(cycle_range), max_cycle + 0.02 * abs(cycle_range), ) y_range = config.y_range if y_range is None: y_range = self._calculate_y_range(data) # Build info_dicts for axis configuration info_dicts = self._build_axis_info_dicts( y, config, number_of_rows, x_range, y_range, eff_lim, xlim_formation, x_label, y_label, max_val_normalized_col, c, ) # Configure facet_kws based on plot type. ``_efficiency`` and # ``_with_rate`` share the same row-0-is-different layout: # disable shared y-axis and give the top row a smaller height. is_efficiency_plot = y.endswith("_efficiency") is_special_top_row = _has_special_top_row(y) if is_special_top_row: facet_kws["sharey"] = False if number_of_rows == 2: gridspec_kws["height_ratios"] = [1, 4] else: logging.debug( f"Special-top-row plot with {number_of_rows} rows - not setting height_ratios" ) facet_kws["gridspec_kws"] = gridspec_kws # Log configuration for debugging logging.debug("Seaborn plot configuration:") logging.debug( f" y={y}, split={config.split}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" ) logging.debug(f" row_id={row_id}, col_id={col_id}") logging.debug(f" is_efficiency_plot={is_efficiency_plot}") logging.debug(f" gridspec_kws={gridspec_kws}") logging.debug(f" additional_kwargs keys: {list(additional_kwargs.keys())}") if config.verbose: logging.info("Seaborn plot configuration:") logging.info( f" y={y}, number_of_rows={number_of_rows}, number_of_cols={number_of_cols}" ) logging.info(f" row_id={row_id}, col_id={col_id}") logging.info(f" is_efficiency_plot={is_efficiency_plot}") logging.info(f" gridspec_kws={gridspec_kws}") logging.info(f" additional_kwargs keys: {list(additional_kwargs.keys())}") # Create the plot # Suppress tight_layout warning from seaborn when using gridspec_kws # (seaborn calls tight_layout internally on axes that may be incompatible) with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=".*tight_layout.*", category=UserWarning, module="seaborn.axisgrid", ) sns_fig = sns.relplot( data=data, x=x, y=self.y_header, hue=self.color, height=seaborn_plot_height, aspect=seaborn_plot_aspect, kind="line", marker="o" if config.markers else None, legend=config.show_legend, **additional_kwargs, facet_kws=facet_kws, ) sns_fig.set_axis_labels(x_label, y_label) # Convert legend labels if requested if config.auto_convert_legend_labels and config.show_legend: self._convert_legend_labels(sns_fig) # Set marker sizes if config.markers: for ax in sns_fig.axes.flat: lines = ax.get_lines() for line in lines: line.set_markersize(seaborn_marker_size) # Apply line hooks if provided if config.seaborn_line_hooks: for ax in sns_fig.axes.flat: lines = ax.get_lines() for line in lines: for hook, args, hook_kwargs in config.seaborn_line_hooks: if hasattr(line, hook): getattr(line, hook)(*args, **hook_kwargs) # Clean up axes and set title fig = sns_fig.figure self._clean_up_axis(fig, info_dicts=info_dicts, row_id=row_id, col_id=col_id) fig.align_ylabels() _hack_to_position_legend = {1: 0.97, 2: 0.95, 3: 0.92, 4: 0.92, 5: 0.92} fig.suptitle(title, y=_hack_to_position_legend.get(number_of_rows, 0.92)) plt.close(fig) return fig
def _calculate_seaborn_plot_properties( self, number_of_rows: int, number_of_cols: int, plot_type: str = "default" ) -> tuple: """Calculate seaborn plot height and aspect ratio.""" if plot_type == "fullcell_standard": _selector = { (4, 1): (2.0, 4.0), (4, 2): (2.0, 2.0), } else: _selector = { (1, 1): (4.0, 2.05), (1, 2): (4.0, 1.0), (2, 1): (2.8, 2.8), (2, 2): (2.8, 1.4), (3, 1): (3.0, 2.7), (3, 2): (3.0, 1.35), (4, 1): (3.0, 2.7), (4, 2): (3.0, 1.35), } return _selector.get((number_of_rows, number_of_cols), (4.0, 1.8)) def _calculate_efficiency_limits(self, data: pd.DataFrame) -> list: """Calculate efficiency axis limits from data.""" eff_vals = ( data.loc[data[self.color].str.contains("_efficiency"), self.y_header] .pipe(pd.to_numeric, errors="coerce") .dropna() ) if len(eff_vals) == 0: return [0, 100] eff_min, eff_max = eff_vals.min(), eff_vals.max() return [eff_min - 0.05 * abs(eff_min), eff_max + 0.05 * abs(eff_max)] def _calculate_y_range(self, data: pd.DataFrame) -> list: """Calculate y-axis range from data.""" y_vals = ( data.loc[~data[self.color].str.contains("_efficiency"), self.y_header] .pipe(pd.to_numeric, errors="coerce") .dropna() ) if len(y_vals) == 0: return [0, 1] min_value, max_value = y_vals.min(), y_vals.max() return [ min_value - 0.05 * abs(min_value), max_value + 0.05 * abs(max_value), ] def _build_axis_info_dicts( self, y: str, config: SummaryPlotConfig, number_of_rows: int, x_range: tuple, y_range: list, eff_lim: Optional[list], xlim_formation: tuple, x_label: str, y_label: str, max_val_normalized_col: float, c: Any, ) -> list: """Build info dictionaries for axis configuration.""" info_dicts = [] is_efficiency_plot = y.endswith("_efficiency") is_fullcell_standard_plot = y.startswith("fullcell_standard_") is_split_constant_voltage_plot = y.endswith("_split_constant_voltage") _efficiency_label = r"Efficiency (%)" if is_efficiency_plot: info_dicts.extend( self._build_efficiency_plot_info_dicts( config, x_range, y_range, eff_lim, xlim_formation, _efficiency_label ) ) elif is_split_constant_voltage_plot: info_dicts.extend( self._build_cv_split_info_dicts( config, number_of_rows, x_range, y_range, config.cv_share_range, xlim_formation, y_label, ) ) elif is_fullcell_standard_plot: info_dicts.extend( self._build_fullcell_standard_info_dicts( config, y, x_range, y_range, eff_lim, config.cv_share_range, config.norm_range, max_val_normalized_col, xlim_formation, c, ) ) else: info_dicts.extend( self._build_standard_info_dicts( config, number_of_rows, x_range, y_range, xlim_formation, y_label, top_row_ylabel=_seaborn_top_row_label(y), ) ) return info_dicts def _build_efficiency_plot_info_dicts( self, config: SummaryPlotConfig, x_range: tuple, y_range: list, eff_lim: Optional[list], xlim_formation: tuple, efficiency_label: str, ) -> list: """Build info dicts for efficiency plots.""" info_dicts = [] if config.show_formation: info_dicts.extend( [ dict( ylabel=efficiency_label, title="", xlim=xlim_formation, ylim=eff_lim, row=0, col="formation", yticks=None, xticks=False, ), dict( ylabel="", title="", xlim=x_range, ylim=eff_lim, row=0, col="standard", yticks=False, xticks=False, ), dict( ylabel="", title="", xlim=xlim_formation, ylim=y_range, row=1, col="formation", yticks=None, xticks=None, ), dict( ylabel="", title="", xlim=x_range, ylim=y_range, row=1, col="standard", yticks=False, xticks=None, ), ] ) else: info_dicts.extend( [ dict( ylabel=efficiency_label, title="", xlim=x_range, ylim=eff_lim, row=0, col=None, yticks=None, xticks=False, ), dict( ylabel="", title="", xlim=x_range, ylim=y_range, row=1, col=None, yticks=None, xticks=None, ), ] ) return info_dicts def _build_cv_split_info_dicts( self, config: SummaryPlotConfig, number_of_rows: int, x_range: tuple, y_range: list, cv_share_range: Optional[list], xlim_formation: tuple, y_label: str, ) -> list: """Build info dicts for CV split plots.""" info_dicts = [] # Row names for CV split plots when split=True row_names = ["all", "without CV", "with CV"] # If split=False, we only have one row if number_of_rows == 1: _d = dict( ylabel=y_label, title="", xlim=x_range, ylim=cv_share_range or y_range, row=None, col=None, yticks=None, xticks=None, ) if config.show_formation: _d["col"] = "standard" _d["yticks"] = False _d["ylabel"] = "" info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=cv_share_range or y_range, row=None, col="formation", yticks=None, xticks=None, ) ) info_dicts.append(_d) else: # Handle 3-row case (all, without CV, with CV) for row_name in row_names[:number_of_rows]: if config.show_formation: # Standard column (second column) - no y-axis labels info_dicts.append( dict( ylabel="", title="", xlim=x_range, ylim=cv_share_range or y_range, row=row_name, col="standard", yticks=False, xticks=True if row_name == row_names[-1] else False, ) ) # Formation column (first column) - with y-axis labels info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=cv_share_range or y_range, row=row_name, col="formation", yticks=True, xticks=True if row_name == row_names[-1] else False, ) ) else: # No formation column, single column plot info_dicts.append( dict( ylabel=y_label if row_name == row_names[0] else "", title="", xlim=x_range, ylim=cv_share_range or y_range, row=row_name, col=None, yticks=True if row_name == row_names[0] else None, xticks=True if row_name == row_names[-1] else False, ) ) return info_dicts def _build_fullcell_standard_info_dicts( self, config: SummaryPlotConfig, y: str, x_range: tuple, y_range: list, eff_lim: Optional[list], cv_share_range: Optional[list], norm_range: Optional[list], max_val_normalized_col: float, xlim_formation: tuple, c: Any, ) -> list: """Build info dicts for fullcell standard plots.""" info_dicts = [] capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1]) ce_label = "Coulombic\nEfficiency (%)" capacity_label = f"Capacity\n({capacity_unit})" loss_label = f"Capacity\nRetention\n({capacity_unit})" if ( config.fullcell_standard_normalization_type and config.fullcell_standard_normalization_factor is not None ): _norm_label = f"[{config.fullcell_standard_normalization_scaler:.1f}/{config.fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity\nRetention (norm.)\n{_norm_label}" else: loss_label = f"Capacity\nRetention\n({capacity_unit})" cv_label = f"CV Capacity\n({capacity_unit})" if config.fullcell_standard_normalization_type is not False: cum_loss_info_range = norm_range or [ 0.0, max( max_val_normalized_col, config.fullcell_standard_normalization_scaler, ), ] else: cum_loss_info_range = norm_range or y_range cv_info = dict( title="", xlim=x_range, ylim=cv_share_range or y_range, row=3, col="standard", yticks=False, xticks=True, ) cum_loss_info = dict( title="", xlim=x_range, ylim=cum_loss_info_range, row=2, col="standard", yticks=False, xticks=False, ) capacity_info = dict( title="", xlim=x_range, ylim=y_range, row=1, col="standard", yticks=False, xticks=False, ) ce_info = dict( title="", xlim=x_range, ylim=eff_lim, row=0, col="standard", yticks=False, xticks=False, ) if not config.show_formation: cv_info["ylabel"] = cv_label cum_loss_info["ylabel"] = loss_label capacity_info["ylabel"] = capacity_label ce_info["ylabel"] = ce_label cv_info["yticks"] = True cum_loss_info["yticks"] = True capacity_info["yticks"] = True ce_info["yticks"] = True info_dicts.extend([cv_info, cum_loss_info, capacity_info, ce_info]) if config.show_formation: info_dicts.extend( [ dict( ylabel=cv_label, title="", xlim=xlim_formation, ylim=cv_share_range or y_range, row=3, col="formation", yticks=True, xticks=True, ), dict( ylabel=loss_label, title="", xlim=xlim_formation, ylim=cum_loss_info_range, row=2, col="formation", yticks=True, xticks=False, ), dict( ylabel=capacity_label, title="", xlim=xlim_formation, ylim=y_range, row=1, col="formation", yticks=True, xticks=False, ), dict( ylabel=ce_label, title="", xlim=xlim_formation, ylim=eff_lim, row=0, col="formation", yticks=True, xticks=False, ), ] ) return info_dicts def _build_standard_info_dicts( self, config: SummaryPlotConfig, number_of_rows: int, x_range: tuple, y_range: list, xlim_formation: tuple, y_label: str, top_row_ylabel: Optional[str] = None, ) -> list: """Build info dicts for standard plots. ``top_row_ylabel`` (when given) overrides the y-axis label on row 0 only; remaining rows keep ``y_label``. Used by ``*_with_rate`` y-sets so the rate row shows "C-rate (1/h)" instead of the capacity label. """ info_dicts = [] is_multi_row = number_of_rows > 1 if is_multi_row: last_row = number_of_rows - 1 for i in range(number_of_rows): row_label = ( top_row_ylabel if (i == 0 and top_row_ylabel) else y_label ) row_ylim = None if (i == 0 and top_row_ylabel) else y_range xticks = None if i == last_row else False info_dicts.append( dict( ylabel="" if config.show_formation else row_label, title="", xlim=x_range, ylim=row_ylim, row=i, col="standard" if config.show_formation else None, yticks=False if config.show_formation else None, xticks=xticks, ) ) if config.show_formation: info_dicts.append( dict( ylabel=row_label, title="", xlim=xlim_formation, ylim=row_ylim, row=i, col="formation", yticks=None, xticks=xticks, ) ) else: _r = 1 if config.split else None _d = dict( ylabel=y_label, title="", xlim=x_range, ylim=y_range, row=_r, col=None, yticks=None, xticks=None, ) if config.show_formation: _d["col"] = "standard" _d["yticks"] = False _d["ylabel"] = "" info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=y_range, row=_r, col="formation", yticks=None, xticks=None, ) ) info_dicts.append(_d) return info_dicts def _valid_number_or_none(self, x: float) -> Optional[float]: """Clean up a number (convert NaN and Inf to None)""" import numbers if isinstance(x, numbers.Number): if not (np.isnan(x) or np.isinf(x)): return x return None def _to_numbers_or_nones(self, x: list) -> list: """Clean up a list of numbers (convert NaN and Inf to None)""" return [self._valid_number_or_none(i) for i in x] def _clean_up_axis(self, fig, info_dicts=None, row_id="row", col_id="cycle_type"): """Clean up and configure axes based on info_dicts.""" if info_dicts is None: return # Create a dictionary with keys the same as the axis titles info_dict = {} for info in info_dicts: if col_id is not None: if row_id is not None: info_text = f"{row_id} = {info['row']} | {col_id} = {info['col']}" else: info_text = f"{col_id} = {info['col']}" else: if row_id is not None: info_text = f"{row_id} = {info['row']}" else: info_text = "single axis" info_dict[info_text] = info # Iterate over the axes and set the properties for a in fig.get_axes(): title_text = a.get_title() if row_id is None and col_id is None: axis_info = info_dict.get("single axis", None) else: axis_info = info_dict.get(title_text, None) if axis_info is None: continue if xlim := axis_info.get("xlim", None): a.set_xlim(self._to_numbers_or_nones(xlim)) if ylim := axis_info.get("ylim", None): a.set_ylim(self._to_numbers_or_nones(ylim)) if ylabel := axis_info.get("ylabel", None): a.set_ylabel(ylabel) a.set_title(axis_info.get("title", "")) xticks = axis_info.get("xticks", False) yticks = axis_info.get("yticks", False) if xticks is False: a.set_xticks([]) if yticks is False: a.set_yticks([]) def _convert_legend_labels(self, sns_fig): """Convert legend labels to nicer format.""" legend = sns_fig.legend if legend is not None: for le in legend.get_texts(): name = le.get_text() name = name.replace("_", " ").title() name = name.replace("Gravimetric", "Grav.") name = name.replace("Cv", "(CV)") name = name.replace("Non (CV)", "(without CV)") le.set_text(name) sns_fig.legend.set_title(None)
[docs] @notebook_docstring_printer def summary_plot_legacy( c, x: Optional[str] = None, y: str = "capacities_gravimetric_coulombic_efficiency", # Consider setting default to 'fullcell_standard_gravimetric' height: Optional[int] = None, width: int = 900, markers: bool = True, title: Optional[str] = None, x_range: Optional[list] = None, y_range: Optional[list] = None, ce_range: Optional[list] = None, norm_range: Optional[list] = None, cv_share_range: Optional[list] = None, split: bool = True, auto_convert_legend_labels: bool = True, interactive: bool = True, share_y: bool = False, rangeslider: bool = False, return_data: bool = False, verbose: bool = False, plotly_template: Optional[str] = None, seaborn_palette: str = "deep", seaborn_style: str = "dark", formation_cycles: int = 3, show_formation: bool = True, show_legend: bool = True, x_axis_domain_formation_fraction: float = 0.2, column_separator: float = 0.01, reset_losses: bool = True, link_capacity_scales: bool = False, fullcell_standard_normalization_type: str = "on-max", fullcell_standard_normalization_factor: Optional[float] = None, fullcell_standard_normalization_scaler: float = 1.0, seaborn_line_hooks: Optional[list[tuple[str, list, dict]]] = None, **kwargs, ) -> Any: """Create a summary plot. Args: c: cellpy object x: x-axis column (default: 'cycle_index') y: y-axis column or column set. Currently, the following predefined sets exists: "voltages", "capacities_gravimetric", "capacities_areal", "capacities_absolute", "capacities_gravimetric_split_constant_voltage", "capacities_areal_split_constant_voltage", "capacities_gravimetric_coulombic_efficiency", "capacities_areal_coulombic_efficiency", "capacities_absolute_coulombic_efficiency", "fullcell_standard_gravimetric", "fullcell_standard_areal", "fullcell_standard_absolute", height: height of the plot (for plotly) width: width of the plot (for plotly) markers: use markers title: title of the plot x_range: limits for x-axis y_range: limits for y-axis ce_range: limits for coulombic efficiency (if present) norm_range: limits for normalized capacity (if present) cv_share_range: limits for cv share (if present) split: split the plot auto_convert_legend_labels: convert the legend labels to a nicer format. interactive: use interactive plotting (plotly) rangeslider: add a range slider to the x-axis (only for plotly) share_y: share y-axis (only for plotly) return_data: return the data used for plotting verbose: print out some extra information to make it easier to find out what to plot next time plotly_template: name of the plotly template to use seaborn_palette: name of the seaborn palette to use seaborn_style: name of the seaborn style to use formation_cycles: number of formation cycles to show show_formation: show formation cycles show_legend: show the legend x_axis_domain_formation_fraction: fraction of the x-axis domain for the formation cycles (default: 0.2) column_separator: separation between columns when splitting the plot (only for plotly) reset_losses: reset the losses to the first cycle (only for fullcell_standard plots) link_capacity_scales: link the capacity scales (only for fullcell_standard plots) fullcell_standard_normalization_type: normalization type for the fullcell standard plots (capacity retention) (divide, multiply, area, max, on-max, False) if normalization_type is on-max, the normalization factor is set to the maximum value of the capacity column if not provided if normalization_type is max, the normalization factor is set to the maximum value of the capacity column if not provided if normalization_type is shift-divide, the normalization is done by shifting the data by the normalization factor and then dividing by the normalization factor if normalization_type is divide, the normalization is done by dividing by the normalization factor and then multiplying by the scaler if normalization_type is multiply, the normalization is done by multiplying by the normalization factor and then multiplying by the scaler if normalization_type is area, the normalization is done by dividing by the area and then multiplying by the scaler if normalization_type is False, no normalization is done fullcell_standard_normalization_factor: normalization factor for the fullcell standard plots fullcell_standard_normalization_scaler: scaler for the fullcell standard plots plotly_[update trace parameter]: additional parameters for the plotly traces (e.g. use plotly_marker_size=10 for updating the marker_size to 10) seaborn_[update line parameter]: additional parameters for the seaborn lines (not many options available yet) (e.g. use seaborn_marker_size=10 for updating the marker_size to 10) seaborn_line_hooks: list of functions to hook into the seaborn lines (e.g. to update the marker_size) **kwargs: includes additional parameters for the plotting backend (not properly documented yet). Returns: if ``return_data`` is True, returns a tuple with the figure and the data used for plotting. Otherwise, it returns only the figure. If ``interactive`` is True, the figure is a ``plotly`` figure, else it is a ``matplotlib`` figure. Hint: If you want to modify the non-interactive (matplotlib) plot, you can get the axes from the returned figure by ``axes = figure.get_axes()``: >> axes = figure.get_axes() >> ylabel = axes[0].get_ylabel() >> if "Coulombic" in ylabel: >> axes[0].set_ylabel("C.E. (%)") >> else: >> print(f"This is not the coulombic efficiency axis: {ylabel=}") """ from copy import deepcopy import re dev_mode = kwargs.pop("dev_mode", False) if dev_mode: print("DEV: dev_mode") smart_link = kwargs.pop("smart_link", True) show_y_labels_on_right_pane = kwargs.pop("show_y_labels_on_right_pane", False) seaborn_facecolor = kwargs.pop("seaborn_facecolor", "#EAEAF2") seaborn_edgecolor = kwargs.pop("seaborn_edgecolor", "black") seaborn_style_dict_default = { "axes.facecolor": seaborn_facecolor, "axes.edgecolor": seaborn_edgecolor, } seaborn_style_dict = kwargs.pop("seaborn_style_dict", seaborn_style_dict_default) seaborn_marker_size = kwargs.pop("seaborn_marker_size", 7) # only used for fullcell_standard plots in interactive mode for now plotly_row_ratios = kwargs.pop( "fullcell_standard_row_height_ratios", [0.3, 0.6, 0.9] ) plotly_row_space = kwargs.pop("fullcell_standard_row_space", 0.02) # fullcell_standard does not respect the split parameter if y.startswith("fullcell_standard_") and not split: logging.debug("fullcell_standard does not respect the split parameter") number_of_rows = 1 max_val_normalized_col = 0.0 if interactive and not plotly_available: warnings.warn( "plotly not available, and it is currently the only supported interactive backend" ) return None if title is None: if interactive: title = f"Summary <b>{c.cell_name}</b>" else: title = f"Summary {c.cell_name}" if x is None: x = "cycle_index" xlim_formation = kwargs.pop("xlim_formation", (0.6, formation_cycles + 0.4)) eff_lim = ce_range if formation_cycles < 1: show_formation = False x_cols, y_cols, x_trans, y_trans = SummaryPlotInfo(c)._create_col_info(c) x_axis_labels, y_axis_label = SummaryPlotInfo(c)._create_label_dict(c) def _auto_range(fig: Any, axis_name_1: str, axis_name_2: str) -> list: # only works for plotly min_y = np.inf max_y = -np.inf full_axis_name_1 = axis_name_1.replace("y", "yaxis") full_axis_name_2 = axis_name_2.replace("y", "yaxis") _range_1 = fig.layout[f"{full_axis_name_1}_range"] _range_2 = fig.layout[f"{full_axis_name_2}_range"] if _range_1 is None: _range_1 = [np.inf, -np.inf] if _range_2 is None: _range_2 = [np.inf, -np.inf] _range = [min(_range_1[0], _range_2[0]), max(_range_1[1], _range_2[1])] for i, t in enumerate(deepcopy(fig.data)): if t.yaxis in [axis_name_1, axis_name_2]: y = deepcopy(t.y) try: y = np.array(y, dtype=float) min_y = np.ma.masked_invalid(y).min() max_y = np.ma.masked_invalid(y).max() except Exception as e: warnings.warn( f"Could not calculate min and max for y-axis (data set {i}): {e}" ) _range = [min(_range[0], min_y), max(_range[1], max_y)] _range = [0.95 * _range[0], 1.05 * _range[1]] return _range y_header = "value" color = "variable" row = "row" col_id = "cycle_type" additional_kwargs_plotly = dict( color=color, height=height, markers=markers, title=title, width=width, ) additional_kwargs_plotly_update_traces = dict() for k in list(kwargs.keys()): if k.startswith("plotly_"): additional_kwargs_plotly_update_traces[k.replace("plotly_", "")] = ( kwargs.pop(k) ) additional_kwargs_seaborn = dict() # ------------------- collecting data ----------------------------------------- if y.startswith("fullcell_standard_"): if additional_kwargs_plotly.get("height") is None: additional_kwargs_plotly["height"] = 800 column_set = y_cols.get(y, y) summary = c.data.summary.copy() if summary.index.name == x: summary = summary.reset_index(drop=False) # Remark! Possible code duplication with the 'partition_summary_cv_steps' used in # the 'if y.endswith("_split_constant_voltage")' block: summary_only_cv = c.make_summary( selector_type="only-cv", create_copy=True ).data.summary if summary_only_cv.index.name == x: summary_only_cv = summary_only_cv.reset_index(drop=False) s = summary.merge(summary_only_cv, on=x, how="outer", suffixes=("", "_cv")) s = s.reset_index(drop=True) s = s.melt(x) s = s.loc[ s.variable.isin(column_set) ] # using strickt naming convention for "duplicated" columns ('mod_<nn>_<column_name>' so it will not be picked up here) number_of_rows = 4 s[row] = 1 # default row for capacity # Set row numbers using regex patterns s.loc[s["variable"].str.contains(r"_efficiency$"), row] = ( 0 # coulombic efficiency ) s.loc[s["variable"].str.contains(r"cumulated.*loss"), row] = ( 2 # cumulated loss [will be removed?] ) s.loc[s["variable"].str.startswith(r"mod_01_"), row] = 2 # capacity retention s.loc[s["variable"].str.contains(r"_cv$"), row] = 3 # cv data additional_kwargs_plotly["facet_row"] = row if reset_losses: # Get the first value for each cumulated loss variable first_values = ( s[s["variable"].str.contains(r"cumulated.*loss")] .groupby("variable")["value"] .transform("first") ) # Shift all values by subtracting the first value mask = s["variable"].str.contains(r"cumulated.*loss") s.loc[mask, "value"] = s.loc[mask, "value"] - first_values if fullcell_standard_normalization_type is not False: if fullcell_standard_normalization_factor is None: # need a special case for the cumloss plots if y.startswith("fullcell_standard_cumloss_"): print("only allowing for 'divide' for cumloss plots") fullcell_standard_normalization_factor = s[s[row] == 1].max().value fullcell_standard_normalization_type = "divide" else: if fullcell_standard_normalization_type == "on-max": fullcell_standard_normalization_factor = ( s[s[row] == 1].max().value ) fullcell_standard_normalization_type = "shift-divide" elif fullcell_standard_normalization_type == "max": fullcell_standard_normalization_factor = ( s[s[row] == 1].max().value ) fullcell_standard_normalization_type = "shift-divide" elif fullcell_standard_normalization_type == "area": with warnings.catch_warnings(): warnings.simplefilter("ignore") area = np.trapezoid(s[s[row] == 1].value, dx=1) fullcell_standard_normalization_factor = area fullcell_standard_normalization_type = "shift-divide" else: fullcell_standard_normalization_factor = 1.0 trans_kwargs = dict( normalization_factor=fullcell_standard_normalization_factor, normalization_type=fullcell_standard_normalization_type, normalization_scaler=fullcell_standard_normalization_scaler, ) # transform the data max_row_val = s[row].max() for col, trans_dict in y_trans.get(y, {}).items(): for (new_row_val, new_col), trans in trans_dict.items(): if new_col in s["variable"].values: # transforming on existing column (not using the new_row_val) s.loc[s["variable"] == col, "value"] = trans( s.loc[s["variable"] == col, "value"].values, **trans_kwargs ) else: # creating new column (using the new_row_val) old_col = col if new_row_val is not None: row_val = new_row_val else: row_val = s.loc[s["variable"] == col, row] if not row_val.empty: row_val = row_val.values[0] else: max_row_val += 1 row_val = max_row_val if old_col.startswith("mod_"): old_col = re.sub(r"^mod_\d{2}_", "", old_col) new_col_frame_section = s.loc[s["variable"] == old_col].copy() new_col_frame_section["variable"] = new_col new_col_frame_section["row"] = row_val transformed_values = trans( new_col_frame_section["value"].values, **trans_kwargs ) new_col_frame_section["value"] = transformed_values s = pd.concat([s, new_col_frame_section], ignore_index=True) s = s.reset_index(drop=True) s = s.sort_values(by=["row", "variable"]) max_val_normalized_col = s.loc[ s["variable"] == new_col, "value" ].max() # filter on constant voltage vs constant current # Remark! uses the 'partition_summary_cv_steps' function - consider using that also for the fullcell standard plot to avoid code duplication elif y.endswith("_split_constant_voltage"): cap_type = ( "capacities_gravimetric" if y.startswith("capacities_gravimetric") else "capacities_areal" ) column_set = y_cols[cap_type] # turning off warnings when splitting the data with warnings.catch_warnings(): warnings.simplefilter("ignore") s = partition_summary_cv_steps(c, x, column_set, split, color, y_header) if split: additional_kwargs_plotly["facet_row"] = row number_of_rows = 3 if additional_kwargs_plotly.get("height") is None: additional_kwargs_plotly["height"] = 800 else: column_set = y_cols.get(y, y) if isinstance(column_set, str): column_set = [column_set] summary = c.data.summary summary = summary.reset_index() s = summary.melt(x) s = s.loc[s.variable.isin(column_set)] s = s.reset_index(drop=True) s[row] = 1 if split: if y.endswith("_efficiency"): s[row] = 1 s.loc[s["variable"].str.contains("efficiency"), row] = 0 additional_kwargs_plotly["facet_row"] = row number_of_rows = 2 if additional_kwargs_plotly.get("height") is None: additional_kwargs_plotly["height"] = 200 + 200 * number_of_rows max_cycle = s[x].max() min_cycle = s[x].min() x_label = x_axis_labels.get(x, x) if y in y_axis_label: y_label = y_axis_label.get(y, y) else: y_label = y.replace("_", " ").title() if split and show_formation and not smart_link: column_separator = max(column_separator, 0.06) show_y_labels_on_right_pane = True formation_cycle_selector = slice(None, None) if formation_cycles > 0: formation_cycle_selector = s[x] <= formation_cycles s[col_id] = "standard" s.loc[formation_cycle_selector, col_id] = "formation" if verbose or dev_mode: _report_summary_plot_info( c, x, y, x_label, x_axis_labels, x_cols, y_label, y_axis_label, y_cols ) if interactive: import plotly.express as px # from plotly.subplots import make_subplots set_plotly_template(plotly_template) if show_formation: additional_kwargs_plotly["facet_col"] = col_id fig = px.line( s, x=x, y=y_header, **additional_kwargs_plotly, labels={ x: x_label, y_header: y_label, }, **kwargs, ) fig.update_traces(**additional_kwargs_plotly_update_traces) if not show_legend: fig.update_layout(showlegend=False) if y_range is not None: fig.update_layout(yaxis=dict(range=y_range)) if show_formation: formation_header = '<span style="color:red">Formation</span>' x_axis_domain_formation = [ 0.0, x_axis_domain_formation_fraction - column_separator / 2, ] x_axis_domain_rest = [ x_axis_domain_formation_fraction + column_separator / 2, 0.95, ] max_cycle_formation = s.loc[formation_cycle_selector, x].max() min_cycle_rest = s.loc[~formation_cycle_selector, x].min() if x == _hdr_summary.normalized_cycle_index: dd = 0.1 else: dd = 0.4 x_axis_range_formation = [min_cycle - dd, max_cycle_formation + dd] x_axis_range_rest = [min_cycle_rest - dd, max_cycle + dd] if x_range is not None: x_axis_range_rest = [ x_axis_range_rest[0], min(x_range[1], x_axis_range_rest[1]), ] if number_of_rows == 1: fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, xaxis=dict(range=x_axis_range_formation), xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None, ), ) annotations = [ { "text": formation_header, "x": 0.08, "y": 1.02, "showarrow": False, }, PLOTLY_BLANK_LABEL, ] fig.update_layout(annotations=annotations) fig.update_layout( yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane ), ) elif number_of_rows == 2: fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) if y.endswith("_efficiency"): fig.update_layout( yaxis3={ "title": dict(text="Coulombic Efficiency"), "domain": [0.7, 1.0], }, yaxis1=dict(domain=[0.0, 0.65]), yaxis2=dict(domain=[0.0, 0.65]), yaxis4=dict(domain=[0.70, 1.0]), ) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = y_range or _auto_range(fig, "y", "y2") range_2 = eff_lim or _auto_range(fig, "y3", "y4") # seems to be problematic for plotly having a range_2 that is [value, inf] ([87.0012, inf]) fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict( matches="y2", range=range_1, ), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict( matches="y4", range=range_2, ), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 3 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations elif number_of_rows == 3: fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = _auto_range(fig, "y", "y2") range_2 = _auto_range(fig, "y3", "y4") range_3 = _auto_range(fig, "y5", "y6") fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis5=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis6=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict(matches="y2", range=range_1), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict(matches="y4", range=range_2), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), yaxis5=dict(matches="y6", range=range_3), yaxis6=dict( matches="y5", showticklabels=show_y_labels_on_right_pane, range=range_3, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 5 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations elif number_of_rows == 4: fig.update_yaxes(matches="y") fig.update_yaxes(autorange=False) fig.update_layout( xaxis_domain=x_axis_domain_formation, scene_domain_x=x_axis_domain_formation, ) range_1 = _auto_range(fig, "y", "y2") if ( y.startswith("fullcell_standard_") and fullcell_standard_normalization_type is not False ): range_2 = [ 0.0, max( max_val_normalized_col, fullcell_standard_normalization_scaler, ), ] range_2 = norm_range or range_2 else: range_2 = _auto_range(fig, "y3", "y4") range_3 = _auto_range(fig, "y5", "y6") range_4 = _auto_range(fig, "y7", "y8") if y.startswith("fullcell_standard_"): range_4 = eff_lim or range_4 range_3 = y_range or range_3 range_1 = cv_share_range or range_1 fig.update_layout( xaxis2=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches=None ), xaxis3=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis4=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis5=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis6=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), xaxis7=dict( range=x_axis_range_formation, domain=x_axis_domain_formation, matches="x", ), xaxis8=dict( range=x_axis_range_rest, domain=x_axis_domain_rest, matches="x2" ), yaxis=dict(matches="y2", range=range_1), yaxis2=dict( matches="y", showticklabels=show_y_labels_on_right_pane, range=range_1, ), yaxis3=dict(matches="y4", range=range_2), yaxis4=dict( matches="y3", showticklabels=show_y_labels_on_right_pane, range=range_2, ), yaxis5=dict(matches="y6", range=range_3), yaxis6=dict( matches="y5", showticklabels=show_y_labels_on_right_pane, range=range_3, ), yaxis7=dict(matches="y8", range=range_4), yaxis8=dict( matches="y7", showticklabels=show_y_labels_on_right_pane, range=range_4, ), ) annotations = [_plotly_label_dict(formation_header, 0.08, 1.0)] + 7 * [ PLOTLY_BLANK_LABEL ] fig.layout["annotations"] = annotations if y.startswith("fullcell_standard_"): ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0 capacity_domain_start, capacity_domain_end = ( plotly_row_ratios[1], plotly_row_ratios[2] - plotly_row_space, ) loss_domain_start, loss_domain_end = ( plotly_row_ratios[0], plotly_row_ratios[1] - plotly_row_space, ) cv_domain_start, cv_domain_end = ( 0.0, plotly_row_ratios[0] - plotly_row_space, ) # Format y-axis labels with HTML for proper alignment mode = y.split("_")[-1] capacity_unit = _get_capacity_unit(c, mode=mode) ce_label = "Coulombic<br>Efficiency (%)" capacity_label = f"Capacity<br>({capacity_unit})" if fullcell_standard_normalization_type: _norm_label = f"[{fullcell_standard_normalization_scaler:.1f}/{fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity<br>Retention (norm.)<br>{_norm_label}" else: loss_label = f"Capacity<br>Retention ({capacity_unit})" cv_label = f"CV Capacity<br>({capacity_unit})" fig.update_layout( yaxis8={"domain": [ce_domain_start, ce_domain_end]}, yaxis7={ "title": dict(text=ce_label), "domain": [ce_domain_start, ce_domain_end], }, yaxis6={"domain": [capacity_domain_start, capacity_domain_end]}, yaxis5={ "title": dict(text=capacity_label), "domain": [capacity_domain_start, capacity_domain_end], }, yaxis4={"domain": [loss_domain_start, loss_domain_end]}, yaxis3={ "title": dict(text=loss_label), "domain": [loss_domain_start, loss_domain_end], }, yaxis2={"domain": [cv_domain_start, cv_domain_end]}, yaxis1={ "title": dict(text=cv_label), "domain": [cv_domain_start, cv_domain_end], }, ) if show_formation: fig.update_layout( xaxis1={"title": dict(text="")}, ) if x_axis_domain_formation_fraction < 0.1: fig.update_layout( xaxis1={"showticklabels": False}, ) if link_capacity_scales: fig.update_layout( yaxis={"matches": "y2"}, yaxis2={"matches": "y3"}, yaxis3={"matches": "y4"}, yaxis4={"matches": "y5"}, yaxis5={"matches": "y6"}, ) else: raise NotImplementedError("Not implemented for more than four rows") else: # TODO: refactor so that we do not have specify this: if y.endswith("_efficiency"): fig.update_layout( yaxis=dict(domain=[0.0, 0.65]), yaxis2={ "title": dict(text="Coulombic Efficiency"), "domain": [0.7, 1.0], }, ) if y.startswith("fullcell_standard_"): range_1 = eff_lim or _auto_range(fig, "y4", "y4") range_2 = y_range or _auto_range(fig, "y3", "y3") range_3 = _auto_range(fig, "y2", "y2") if fullcell_standard_normalization_type is not False: range_3 = [ 0.0, max( max_val_normalized_col, fullcell_standard_normalization_scaler, ), ] range_3 = norm_range or range_3 range_4 = cv_share_range or _auto_range(fig, "y", "y") fig.layout["annotations"] = 4 * [PLOTLY_BLANK_LABEL] ce_domain_start, ce_domain_end = plotly_row_ratios[2], 1.0 capacity_domain_start, capacity_domain_end = ( plotly_row_ratios[1], plotly_row_ratios[2] - plotly_row_space, ) loss_domain_start, loss_domain_end = ( plotly_row_ratios[0], plotly_row_ratios[1] - plotly_row_space, ) cv_domain_start, cv_domain_end = ( 0.0, plotly_row_ratios[0] - plotly_row_space, ) # Format y-axis labels with HTML for proper alignment capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1]) ce_label = "Coulombic<br>Efficiency (%)" capacity_label = f"Capacity<br>({capacity_unit})" if fullcell_standard_normalization_type: _norm_label = f"[{fullcell_standard_normalization_scaler:.1f}/{fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity<br>Retention (norm.)<br>{_norm_label}" else: loss_label = f"Capacity<br>Retention ({capacity_unit})" cv_label = f"CV Capacity<br>({capacity_unit})" fig.update_layout( yaxis4={ "title": dict(text=ce_label), "domain": [ce_domain_start, ce_domain_end], "matches": None, "range": range_1, }, yaxis3={ "title": dict(text=capacity_label), "domain": [capacity_domain_start, capacity_domain_end], "matches": None, "range": range_2, }, yaxis2={ "title": dict(text=loss_label), "domain": [loss_domain_start, loss_domain_end], "matches": None, "range": range_3, }, yaxis={ "title": dict(text=cv_label), "domain": [cv_domain_start, cv_domain_end], "matches": None, "range": range_4, }, ) if x_range is not None: if not show_formation: fig.update_layout(xaxis=dict(range=x_range)) # The x_range is handled a bit differently when showing formation cycles # This is done within if show_formation block if split: if show_formation: if not share_y and not smart_link: fig.update_yaxes(matches=None) elif not share_y: fig.update_yaxes(matches=None) if rangeslider: if show_formation: logging.critical( "Can not add rangeslider when showing formation cycles" ) else: fig.update_layout(xaxis_rangeslider_visible=True) if auto_convert_legend_labels and show_legend: for trace in fig.data: name = trace.name name = name.replace("_", " ").title() name = name.replace("Gravimetric", "Grav.") name = name.replace("Cv", "(CV)") name = name.replace("Non (CV)", "(without CV)") hover_template = trace.hovertemplate statements = [] for statement in hover_template.split("<br>"): variable, value = statement.split("=") if value.startswith("%{y}"): variable = name statement = "=".join((variable, value)) statements.append(statement) hover_template = "<br>".join(statements) trace.update(name=name, hovertemplate=hover_template) if return_data: return fig, s return fig else: if not seaborn_available: warnings.warn( "seaborn not available, returning only the data so that you can plot it yourself instead" ) return s import seaborn as sns def _clean_up_axis(fig, info_dicts=None, row_id="row", col_id="cycle_type"): # creating a dictionary with keys the same as the axis titles: info_dict = {} for info in info_dicts: if col_id is not None: if row_id is not None: info_text = ( f"{row_id} = {info['row']} | {col_id} = {info['col']}" ) else: info_text = f"{col_id} = {info['col']}" else: if row_id is not None: info_text = f"{row_id} = {info['row']}" else: info_text = "single axis" info_dict[info_text] = info # iterating over the axes and setting the properties: for a in fig.get_axes(): title_text = a.get_title() if row_id is None and col_id is None: axis_info = info_dict["single axis"] else: axis_info = info_dict.get(title_text, None) if axis_info is None: continue if xlim := axis_info.get("xlim", None): a.set_xlim(xlim) if ylim := axis_info.get("ylim", None): a.set_ylim(ylim) if ylabel := axis_info.get("ylabel", None): a.set_ylabel(ylabel) a.set_title(axis_info.get("title", "")) xticks = axis_info.get("xticks", False) yticks = axis_info.get("yticks", False) if xticks is False: a.set_xticks([]) if yticks is False: a.set_yticks([]) sns.set_style(seaborn_style, seaborn_style_dict) sns.set_palette(seaborn_palette) sns.set_context(kwargs.pop("seaborn_context", "notebook")) facet_kws = dict(despine=False, sharex=False, sharey=False) gridspec_kws = dict(hspace=0.07) if show_formation: additional_kwargs_seaborn["col"] = col_id number_of_cols = 2 gridspec_kws["width_ratios"] = kwargs.pop("width_ratios", [1, 6]) gridspec_kws["wspace"] = kwargs.pop("wspace", 0.02) else: number_of_cols = 1 col_id = None if not split: number_of_rows = 1 row_id = None else: row_id = row additional_kwargs_seaborn["row"] = row number_of_rows = s[row].nunique() def _calculate_seaborn_plot_properties( number_of_rows, number_of_cols, plot_type="default" ): ## Maybe implement some proper calculations later... # _default_seaborn_plot_height = 2.4 + 0.4 * number_of_rows # _default_seaborn_plot_aspect = 1.0 + 2.0 / number_of_rows # seaborn_plot_height = 2.0 # hardcoded for now # seaborn_plot_aspect = 1.5 if show_formation else 3.0 # hardcoded for now if plot_type == "fullcell_standard": _selector = { (4, 1): (2.0, 4.0), (4, 2): (2.0, 2.0), } else: _selector = { (1, 1): (4.0, 2.05), (1, 2): (4.0, 1.0), (2, 1): (2.8, 2.8), (2, 2): (2.8, 1.4), (3, 1): (3.0, 2.7), (3, 2): (3.0, 1.35), (4, 1): (3.0, 2.7), (4, 2): (3.0, 1.35), } return _selector.get((number_of_rows, number_of_cols), (4.0, 1.8)) if y.startswith("fullcell_standard_"): plot_type = "fullcell_standard" else: plot_type = "default" _default_seaborn_plot_height, _default_seaborn_plot_aspect = ( _calculate_seaborn_plot_properties( number_of_rows, number_of_cols, plot_type=plot_type ) ) seaborn_plot_height = kwargs.pop( "seaborn_plot_height", _default_seaborn_plot_height ) seaborn_plot_aspect = kwargs.pop( "seaborn_plot_aspect", _default_seaborn_plot_aspect ) is_efficiency_plot = y.endswith("_efficiency") is_fullcell_standard_plot = y.startswith("fullcell_standard_") is_split_constant_voltage_plot = y.endswith("_split_constant_voltage") is_multi_row = number_of_rows > 1 info_dicts = [] # axis limits: if eff_lim is None: eff_vals = ( s.loc[s[color].str.contains("_efficiency"), y_header] .replace([np.inf, -np.inf], np.nan) .dropna() ) eff_min, eff_max = eff_vals.min(), eff_vals.max() eff_lim = [eff_min - 0.05 * abs(eff_min), eff_max + 0.05 * abs(eff_max)] if x_range is None: cycle_range = max_cycle - formation_cycles if cycle_range <= 0: cycle_range = 10 # arbitrary value x_range = ( formation_cycles + 1 - 0.02 * abs(cycle_range), max_cycle + 0.02 * abs(cycle_range), ) if y_range is None: y_vals = ( s.loc[~s[color].str.contains("_efficiency"), y_header] .replace([np.inf, -np.inf], np.nan) .dropna() ) min_value, max_value = y_vals.min(), y_vals.max() y_range = y_range or [ min_value - 0.05 * abs(min_value), max_value + 0.05 * abs(max_value), ] _efficiency_label = r"Efficiency (%)" if is_efficiency_plot: facet_kws["sharey"] = False gridspec_kws["height_ratios"] = [1, 4] if show_formation: info_dicts.append( dict( ylabel=_efficiency_label, title="", xlim=xlim_formation, ylim=eff_lim, row=0, col="formation", yticks=None, xticks=False, ) ) info_dicts.append( dict( ylabel="", title="", xlim=x_range, ylim=eff_lim, row=0, col="standard", yticks=False, xticks=False, ) ) info_dicts.append( dict( ylabel="", title="", xlim=xlim_formation, ylim=y_range, row=1, col="formation", yticks=None, xticks=None, ) ) info_dicts.append( dict( ylabel="", title="", xlim=x_range, ylim=y_range, row=1, col="standard", yticks=False, xticks=None, ) ) else: info_dicts.append( dict( ylabel=_efficiency_label, title="", xlim=x_range, ylim=eff_lim, row=0, col=None, yticks=None, xticks=False, ) ) info_dicts.append( dict( ylabel="", title="", xlim=x_range, ylim=y_range, row=1, col=None, yticks=None, xticks=None, ) ) elif is_split_constant_voltage_plot: if is_multi_row: cv_share_range = cv_share_range or y_range for r, _x, _y_range in zip( ["all", "without CV", "with CV"], [False, False, None], [y_range, y_range, cv_share_range], ): _d = dict( ylabel=y_label, title="", xlim=x_range, ylim=_y_range, row=r, col=None, yticks=None, xticks=_x, ) if show_formation: _d["col"] = "standard" _d["yticks"] = False _d["ylabel"] = "" info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=_y_range, row=r, col="formation", yticks=None, xticks=_x, ) ) info_dicts.append(_d) else: _d = dict( ylabel=y_label, title="", xlim=x_range, ylim=y_range, row=None, col=None, yticks=None, xticks=None, ) if show_formation: _d["col"] = "standard" _d["yticks"] = False _d["ylabel"] = "" info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=y_range, row=None, col="formation", yticks=None, xticks=None, ) ) info_dicts.append(_d) elif is_fullcell_standard_plot: capacity_unit = _get_capacity_unit(c, mode=y.split("_")[-1]) ce_label = "Coulombic\nEfficiency (%)" capacity_label = f"Capacity\n({capacity_unit})" loss_label = f"Capacity\nRetention\n({capacity_unit})" if fullcell_standard_normalization_type: _norm_label = f"[{fullcell_standard_normalization_scaler:.1f}/{fullcell_standard_normalization_factor:.1f} {capacity_unit}]" loss_label = f"Capacity\nRetention (norm.)\n{_norm_label}" else: loss_label = f"Capacity\nRetention\n({capacity_unit})" cv_label = f"CV Capacity\n({capacity_unit})" facet_kws["sharey"] = False gridspec_kws["height_ratios"] = [1, 3, 3, 3] number_of_rows = 4 if fullcell_standard_normalization_type is not False: cum_loss_info_range = norm_range or [ 0.0, max(max_val_normalized_col, fullcell_standard_normalization_scaler), ] else: cum_loss_info_range = norm_range or y_range cv_info = dict( title="", xlim=x_range, ylim=cv_share_range or y_range, row=3, col="standard", yticks=False, xticks=True, ) cum_loss_info = dict( title="", xlim=x_range, ylim=cum_loss_info_range, row=2, col="standard", yticks=False, xticks=False, ) capacity_info = dict( title="", xlim=x_range, ylim=y_range, row=1, col="standard", yticks=False, xticks=False, ) ce_info = dict( title="", xlim=x_range, ylim=eff_lim, row=0, col="standard", yticks=False, xticks=False, ) if not show_formation: cv_info["ylabel"] = cv_label cum_loss_info["ylabel"] = loss_label capacity_info["ylabel"] = capacity_label ce_info["ylabel"] = ce_label cv_info["yticks"] = True cum_loss_info["yticks"] = True capacity_info["yticks"] = True ce_info["yticks"] = True info_dicts.append(cv_info) info_dicts.append(cum_loss_info) info_dicts.append(capacity_info) info_dicts.append(ce_info) if show_formation: cv_info_formation = dict( ylabel=cv_label, title="", xlim=xlim_formation, ylim=cv_share_range or y_range, row=3, col="formation", yticks=True, xticks=True, ) loss_info_formation = dict( ylabel=loss_label, title="", xlim=xlim_formation, ylim=cum_loss_info_range, row=2, col="formation", yticks=True, xticks=False, ) cap_info_formation = dict( ylabel=capacity_label, title="", xlim=xlim_formation, ylim=y_range, row=1, col="formation", yticks=True, xticks=False, ) ce_info_formation = dict( ylabel=ce_label, title="", xlim=xlim_formation, ylim=eff_lim, row=0, col="formation", yticks=True, xticks=False, ) info_dicts.append(cv_info_formation) info_dicts.append(loss_info_formation) info_dicts.append(cap_info_formation) info_dicts.append(ce_info_formation) if verbose: print(f"{y_header=}") print(f"{color=}") print(f"{seaborn_plot_height=}") print(f"{seaborn_plot_aspect=}") print(f"{markers=}") print(f"{additional_kwargs_seaborn=}") print(f"{facet_kws=}") print(f"{kwargs=}") print(f"{info_dicts=}") else: if is_multi_row: for i in range(number_of_rows): info_dicts.append( dict( ylabel=y_label, title="", xlim=x_range, ylim=y_range, row=i, col=None, yticks=None, xticks=False, ) ) if show_formation: info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=y_range, row=i, col="formation", yticks=None, xticks=False, ) ) else: _r = 1 if split else None _d = dict( ylabel=y_label, title="", xlim=x_range, ylim=y_range, row=_r, col=None, yticks=None, xticks=None, ) if show_formation: _d["col"] = "standard" _d["yticks"] = False _d["ylabel"] = "" info_dicts.append( dict( ylabel=y_label, title="", xlim=xlim_formation, ylim=y_range, row=_r, col="formation", yticks=None, xticks=None, ) ) info_dicts.append(_d) facet_kws["gridspec_kws"] = gridspec_kws sns_fig = sns.relplot( data=s, x=x, y=y_header, hue=color, height=seaborn_plot_height, aspect=seaborn_plot_aspect, kind="line", marker="o" if markers else None, legend=show_legend, **additional_kwargs_seaborn, facet_kws=facet_kws, **kwargs, ) sns_fig.set_axis_labels(x_label, y_label) if auto_convert_legend_labels and show_legend: legend = sns_fig.legend if legend is not None: for le in legend.get_texts(): name = le.get_text() name = name.replace("_", " ").title() name = name.replace("Gravimetric", "Grav.") name = name.replace("Cv", "(CV)") name = name.replace("Non (CV)", "(without CV)") le.set_text(name) sns_fig.legend.set_title(None) if markers: for ax in sns_fig.axes.flat: lines = ax.get_lines() for line in lines: line.set_markersize(seaborn_marker_size) if seaborn_line_hooks: for ax in sns_fig.axes.flat: lines = ax.get_lines() for line in lines: for hook, args, kwargs in seaborn_line_hooks: if hasattr(line, hook): getattr(line, hook)(*args, **kwargs) fig = sns_fig.figure _clean_up_axis(fig, info_dicts=info_dicts, row_id=row_id, col_id=col_id) fig.align_ylabels() _hack_to_position_legend = {1: 0.97, 2: 0.95, 3: 0.92, 4: 0.92, 5: 0.92} fig.suptitle(title, y=_hack_to_position_legend[number_of_rows]) plt.close(fig) if return_data: return fig, s return fig
[docs] @notebook_docstring_printer def summary_plot( c, x: Optional[str] = None, y: str = "capacities_gravimetric_coulombic_efficiency", height: Optional[int] = None, width: int = 900, markers: bool = True, title: Optional[str] = None, x_range: Optional[list] = None, y_range: Optional[list] = None, ce_range: Optional[list] = None, norm_range: Optional[list] = None, cv_share_range: Optional[list] = None, split: bool = True, hover_columns: Optional[list] = None, auto_convert_legend_labels: bool = True, interactive: bool = True, share_y: bool = False, rangeslider: bool = False, return_data: bool = False, verbose: bool = False, plotly_template: Optional[str] = None, seaborn_palette: str = "deep", seaborn_style: str = "dark", formation_cycles: int = 3, show_formation: bool = True, show_legend: bool = True, x_axis_domain_formation_fraction: float = 0.2, column_separator: float = 0.01, reset_losses: bool = True, link_capacity_scales: bool = False, fullcell_standard_normalization_type: str = "max", fullcell_standard_normalization_factor: Optional[float] = None, fullcell_standard_normalization_scaler: float = 1.0, fullcell_standard_normalization_cycle_numbers: Optional[list[int]] = None, seaborn_line_hooks: Optional[list[tuple[str, list, dict]]] = None, filters: Optional[dict] = None, nominal_capacity: Optional[float] = None, rate_filter_columns: Optional[Union[str, tuple, list]] = None, **kwargs, ) -> Any: """Create a summary plot. This is a wrapper around summary_plot_legacy for backwards compatibility. During refactoring, this will be gradually replaced with a new implementation. Args: c: cellpy object x: x-axis column (default: 'cycle_index') y: y-axis column or column set. Currently, the following predefined sets exists: "voltages", "capacities_gravimetric", "capacities_areal", "capacities_absolute", "capacities_gravimetric_split_constant_voltage", "capacities_areal_split_constant_voltage", "capacities_gravimetric_coulombic_efficiency", "capacities_areal_coulombic_efficiency", "capacities_absolute_coulombic_efficiency", "capacities_gravimetric_with_rate", "capacities_areal_with_rate", "capacities_absolute_with_rate", "fullcell_standard_gravimetric", "fullcell_standard_areal", "fullcell_standard_absolute", height: height of the plot (for plotly) width: width of the plot (for plotly) markers: use markers title: title of the plot x_range: limits for x-axis y_range: limits for y-axis ce_range: limits for coulombic efficiency (if present) norm_range: limits for normalized capacity (if present) cv_share_range: limits for cv share (if present) split: split the plot hover_columns: columns to show in the hover tooltip (only for plotly) auto_convert_legend_labels: convert the legend labels to a nicer format. interactive: use interactive plotting (plotly) rangeslider: add a range slider to the x-axis (only for plotly) share_y: share y-axis (only for plotly) return_data: return the data used for plotting verbose: print out some extra information to make it easier to find out what to plot next time plotly_template: name of the plotly template to use seaborn_palette: name of the seaborn palette to use seaborn_style: name of the seaborn style to use formation_cycles: number of formation cycles to show show_formation: show formation cycles show_legend: show the legend x_axis_domain_formation_fraction: fraction of the x-axis domain for the formation cycles (default: 0.2) column_separator: separation between columns when splitting the plot (only for plotly) reset_losses: reset the losses to the first cycle (only for fullcell_standard plots) link_capacity_scales: link the capacity scales (only for fullcell_standard plots) fullcell_standard_normalization_type: normalization type for the fullcell standard plots (capacity retention) (divide, multiply, area, max, on-max, False) fullcell_standard_normalization_factor: normalization factor for the fullcell standard plots fullcell_standard_normalization_scaler: scaler for the fullcell standard plots fullcell_standard_normalization_cycle_numbers: cycle numbers to use for normalization (only for fullcell_standard plots) seaborn_line_hooks: list of functions to hook into the seaborn lines (e.g. to update the marker_size) filters: optional dict forwarded to :func:`cellpy.filters.filter_summary` to drop rows from the summary before plotting (e.g. ``filters={"rate": (0, 0.5)}`` drops slow-rate characterisation cycles). See :func:`cellpy.filters.filter_summary` for range semantics. nominal_capacity: optional plain float in ``c.cellpy_units.nominal_capacity`` units. When given, the ``charge_c_rate`` / ``discharge_c_rate`` columns are rescaled to use this nominal capacity instead of ``c.data.nom_cap`` (multiplies rates by ``c.data.nom_cap / nominal_capacity``). rate_filter_columns: optional override for which rate column(s) the ``rate`` filter targets. Defaults to both ``(charge_c_rate, discharge_c_rate)``; pass a single string (e.g. ``"discharge_c_rate"``) to filter only one side. **kwargs: includes additional parameters for the plotting backend (not properly documented yet). Returns: if ``return_data`` is True, returns a tuple with the figure and the data used for plotting. Otherwise, it returns only the figure. If ``interactive`` is True, the figure is a ``plotly`` figure, else it is a ``matplotlib`` figure. Examples: Default plot (capacity and Coulombic efficiency vs cycle number):: >>> from cellpy.utils.plotutils import summary_plot >>> fig = summary_plot(c) >>> fig.show() Plot gravimetric capacity alone, with formation cycles disabled:: >>> fig = summary_plot(c, y="capacities_gravimetric", show_formation=False) Use the non-interactive (matplotlib/seaborn) backend, e.g. for an SVG export from a script:: >>> fig = summary_plot(c, y="capacities_gravimetric", interactive=False) >>> fig.savefig("summary.svg") Get the prepared DataFrame back together with the figure (useful for custom annotations or follow-up analysis):: >>> fig, data = summary_plot(c, y="capacities_gravimetric", return_data=True) >>> data.head() New ``*_with_rate`` y-set adds a C-rate subplot on row 0:: >>> fig = summary_plot(c, y="capacities_gravimetric_with_rate") Drop slow-rate characterisation cycles (e.g. keep only rows where both ``charge_c_rate`` and ``discharge_c_rate`` are above 0.1):: >>> fig = summary_plot( ... c, ... y="capacities_gravimetric", ... filters={"rate": (0.1, 10.0)}, ... ) Same idea using the symmetric ``{value, delta}`` form to keep rows close to a target C/2 rate:: >>> fig = summary_plot( ... c, ... y="capacities_gravimetric_with_rate", ... filters={"rate": {"value": 0.5, "delta": 0.05}}, ... ) Filter on the discharge rate only (charge rate is ignored):: >>> fig = summary_plot( ... c, ... y="capacities_gravimetric", ... filters={"rate": (0.1, 1.0)}, ... rate_filter_columns="discharge_c_rate", ... ) Override the nominal capacity used for the C-rate axis without re-running ``make_summary``. The rate columns are rescaled by ``c.data.nom_cap / nominal_capacity``; here we both rescale and filter in the new units:: >>> fig = summary_plot( ... c, ... y="capacities_gravimetric_with_rate", ... nominal_capacity=200.0, ... filters={"rate": (0.1, 5.0)}, ... ) The same filter is available without plotting via :meth:`CellpyCell.filtered_summary` (returns a DataFrame copy):: >>> trimmed = c.filtered_summary(rate=(0.1, 10.0)) Or as a free function on any summary-shaped DataFrame:: >>> from cellpy.filters import filter_summary >>> trimmed = filter_summary(c.data.summary.reset_index(), ... rate=(0.1, 10.0)) """ # Create config from parameters config = SummaryPlotConfig.from_kwargs( x=x, y=y, height=height, width=width, markers=markers, title=title, x_range=x_range, y_range=y_range, ce_range=ce_range, norm_range=norm_range, cv_share_range=cv_share_range, split=split, hover_columns=hover_columns, auto_convert_legend_labels=auto_convert_legend_labels, interactive=interactive, share_y=share_y, rangeslider=rangeslider, return_data=return_data, verbose=verbose, plotly_template=plotly_template, seaborn_palette=seaborn_palette, seaborn_style=seaborn_style, formation_cycles=formation_cycles, show_formation=show_formation, show_legend=show_legend, x_axis_domain_formation_fraction=x_axis_domain_formation_fraction, column_separator=column_separator, reset_losses=reset_losses, link_capacity_scales=link_capacity_scales, fullcell_standard_normalization_type=fullcell_standard_normalization_type, fullcell_standard_normalization_factor=fullcell_standard_normalization_factor, fullcell_standard_normalization_scaler=fullcell_standard_normalization_scaler, fullcell_standard_normalization_cycle_numbers=fullcell_standard_normalization_cycle_numbers, seaborn_line_hooks=seaborn_line_hooks, filters=filters, nominal_capacity=nominal_capacity, rate_filter_columns=rate_filter_columns, **kwargs, ) # Check if interactive mode is requested and plotly is available if config.interactive: if not plotly_available: warnings.warn( "plotly not available, and it is currently the only supported interactive backend" ) return None # Prepare data plot_info = SummaryPlotInfo(c) preparer = SummaryPlotDataPreparer() prepared_data_info = preparer.prepare_data( c, config, plot_info, ) builder = PlotlyPlotBuilder() if config.interactive else SeabornPlotBuilder() fig = builder.build_plot( prepared_data_info["data"], prepared_data_info, config, config.additional_kwargs, c, ) if config.return_data: return fig, prepared_data_info["data"] return fig
def _report_summary_plot_info( c, x, y, x_label, x_axis_labels, x_cols, y_label, y_axis_label, y_cols ): from pprint import pprint, pformat import textwrap print("Running summary_plot in verbose mode\n") print("Selected columns:") print(60 * "-") print(f"x: {x}") print(f"y: {y}") print("\nSelected Labels:") print(60 * "-") print(f"x: {x_label}") print(f"y: {y_label}") print("\nAvailable x-columns:") print(60 * "-") for col in x_cols[0]: print(f"{col}") print("\nAvailable y-columns sets:") print(60 * "-") for key, cols in y_cols.items(): print(f"{key}:") for line in textwrap.wrap(pformat(cols, width=60), width=60): print(" " + line) print("\nAvailable y-columns:") print(60 * "-") cols = list(c.data.summary.columns) for line in textwrap.wrap(pformat(cols, width=60), width=60): print(" " + line) print("\nAvailable pre-defined labels:") print(60 * "-") print("x_axis_labels") pprint(x_axis_labels) print("y_axis_label") pprint(y_axis_label)
[docs] def partition_summary_cv_steps( c, x: str, column_set: list, split: bool = False, var_name: str = "variable", value_name: str = "value", ): """Partition the summary data into CV and non-CV steps. Args: c: cellpy object x: x-axis column name column_set: names of columns to include split: add additional column that can be used to split the data when plotting. var_name: name of the variable column after melting value_name: name of the value column after melting Returns: ``pandas.DataFrame`` (melted with columns x, var_name, value_name, and optionally "row" if split is True) """ import pandas as pd summary = c.data.summary summary_no_cv = c.make_summary( selector_type="non-cv", create_copy=True ).data.summary summary_only_cv = c.make_summary( selector_type="only-cv", create_copy=True ).data.summary if x != summary.index.name: summary.set_index(x, inplace=True) summary_no_cv.set_index(x, inplace=True) summary_only_cv.set_index(x, inplace=True) summary = summary[column_set] summary_no_cv = summary_no_cv[column_set] summary_no_cv.columns = [col + "_non_cv" for col in summary_no_cv.columns] summary_only_cv = summary_only_cv[column_set] summary_only_cv.columns = [col + "_cv" for col in summary_only_cv.columns] if split: id_vars = [x, "row"] summary_no_cv["row"] = "without CV" summary_only_cv["row"] = "with CV" summary["row"] = "all" else: id_vars = x summary_no_cv = summary_no_cv.reset_index() summary_only_cv = summary_only_cv.reset_index() summary = summary.reset_index() summary_no_cv = summary_no_cv.melt( id_vars, var_name=var_name, value_name=value_name ) summary_only_cv = summary_only_cv.melt( id_vars, var_name=var_name, value_name=value_name ) summary = summary.melt(id_vars, var_name=var_name, value_name=value_name) s = pd.concat([summary, summary_no_cv, summary_only_cv], axis=0) s = s.reset_index(drop=True) return s
[docs] def raw_plot( cell, y=None, y_label=None, x=None, x_label=None, title=None, interactive=True, plot_type="voltage-current", double_y=True, **kwargs, ): """Plot raw data. Args: cell: cellpy object y (str or list): y-axis column y_label (str or list): label for y-axis x (str): x-axis column x_label (str): label for x-axis title (str): title of the plot interactive (bool): use interactive plotting plot_type (str): type of plot (defaults to "voltage-current") (overrides given y if y is not None), currently only "voltage-current", "raw", "capacity", "capacity-current", and "full" is supported. double_y (bool): use double y-axis (only for matplotlib and when plot_type with 2 rows is used) **kwargs: additional parameters for the plotting backend Returns: ``matplotlib`` figure or ``plotly`` figure """ from cellpy.readers.data_structures import Q _set_individual_y_labels = False _special_height = None raw = cell.data.raw.copy() if y is not None: if y_label is None: y_label = y y = [y] y_label = [y_label] elif plot_type is not None: # special pre-defined plot types if plot_type == "voltage-current": y1 = _hdr_raw["voltage_txt"] y1_label = f"Voltage ({cell.data.raw_units.voltage})" y2 = _hdr_raw["current_txt"] y2_label = f"Current ({cell.data.raw_units.current})" y = [y1, y2] y_label = [y1_label, y2_label] elif plot_type == "capacity": _y = [ ( _hdr_raw["charge_capacity_txt"], f"Charge capacity ({cell.data.raw_units.charge})", ), ( _hdr_raw["discharge_capacity_txt"], f"Discharge capacity ({cell.data.raw_units.charge})", ), ] y, y_label = zip(*_y) elif plot_type == "raw": _y = [ ( _hdr_raw["cycle_index_txt"], f"Cycle index (#)", ), ( _hdr_raw["step_index_txt"], f"Step index (#)", ), (_hdr_raw["voltage_txt"], f"Voltage ({cell.data.raw_units.voltage})"), (_hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), ] y, y_label = zip(*_y) _special_height = 600 elif plot_type == "capacity-current": _y = [ ( _hdr_raw["charge_capacity_txt"], f"Charge capacity ({cell.data.raw_units.charge})", ), ( _hdr_raw["discharge_capacity_txt"], f"Discharge capacity ({cell.data.raw_units.charge})", ), (_hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), ] y, y_label = zip(*_y) _special_height = 500 elif plot_type == "full": _y = [ (_hdr_raw["voltage_txt"], f"Voltage ({cell.data.raw_units.voltage})"), (_hdr_raw["current_txt"], f"Current ({cell.data.raw_units.current})"), ( _hdr_raw["charge_capacity_txt"], f"Charge capacity ({cell.data.raw_units.charge})", ), ( _hdr_raw["discharge_capacity_txt"], f"Discharge capacity ({cell.data.raw_units.charge})", ), ( _hdr_raw["cycle_index_txt"], f"Cycle index (#)", ), ( _hdr_raw["step_index_txt"], f"Step index (#)", ), ] y, y_label = zip(*_y) _special_height = 800 else: warnings.warn(f"Plot type {plot_type} not supported") return None else: # default to voltage if y is not given y = [_hdr_raw["voltage_txt"]] y_label = [f"Voltage ({cell.data.raw_units.voltage})"] if x is None: x = "test_time_hrs" if x in ["test_time_hrs", "test_time_hours"]: raw_time_unit = cell.raw_units.time conv_factor = Q(raw_time_unit).to("hours").magnitude raw[x] = raw[_hdr_raw["test_time_txt"]] * conv_factor x_label = x_label or "Time (hours)" elif x == "test_time_days": raw_time_unit = cell.raw_units.time conv_factor = Q(raw_time_unit).to("days").magnitude raw[x] = raw[_hdr_raw["test_time_txt"]] * conv_factor x_label = x_label or "Time (days)" elif x == "test_time_years": raw_time_unit = cell.raw_units.time conv_factor = Q(raw_time_unit).to("years").magnitude raw[x] = raw[_hdr_raw["test_time_txt"]] * conv_factor x_label = x_label or "Time (years)" if title is None: title = f"{cell.cell_name}" number_of_rows = len(y) if plotly_available and interactive: title = f"<b>{title}</b>" if number_of_rows == 1: # single plot import plotly.express as px if x_label or y_label: labels = {} if x_label: labels[x] = x_label if y_label: labels[y[0]] = y_label[0] else: labels = None fig = px.line(raw, x=x, y=y[0], title=title, labels=labels, **kwargs) else: from plotly.subplots import make_subplots import plotly.graph_objects as go width = kwargs.pop("width", 1000) height = kwargs.pop("height", None) if height is None: if _special_height is not None: height = _special_height else: height = number_of_rows * 300 vertical_spacing = kwargs.pop("vertical_spacing", 0.02) fig = make_subplots( rows=number_of_rows, cols=1, shared_xaxes=True, vertical_spacing=vertical_spacing, x_title=x_label, # hoversubplots="axis", # only available in plotly 5.21 ) x_values = raw[x] rows = range(1, number_of_rows + 1) for i in range(number_of_rows): fig.add_trace( go.Scatter(x=x_values, y=raw[y[i]], name=y_label[i]), row=rows[i], col=1, **kwargs, ) fig.update_layout(height=height, width=width, title_text=title) if _set_individual_y_labels: for i in range(number_of_rows): fig.update_yaxes(title_text=y_label[i], row=rows[i], col=1) return fig # default to a simple matplotlib figure xlim = kwargs.get("xlim") figsize = kwargs.pop("figsize", (10, 2 * number_of_rows)) if seaborn_available: import seaborn as sns if double_y: sns.set_style(kwargs.pop("style", "dark")) else: sns.set_style(kwargs.pop("style", "darkgrid")) if len(y) == 1: y = y[0] y_label = y_label[0] fig, ax = plt.subplots(figsize=figsize) ax.plot(raw[x], raw[y]) ax.set_xlabel(x_label) ax.set_ylabel(y_label) ax.set_title(title) ax.set_xlim(xlim) plt.close(fig) return fig elif len(y) == 2 and double_y: fig, ax_v = plt.subplots(figsize=figsize) color = "tab:red" ax_v.set_xlabel(x_label) ax_v.set_ylabel(y_label[0], color=color) ax_v.plot(raw[x], raw[y[0]], label=y_label[0], color=color) ax_v.tick_params(axis="y", labelcolor=color) ax_c = ax_v.twinx() color = "tab:blue" ax_c.set_ylabel(y_label[1], color=color) ax_c.plot(raw[x], raw[y[1]], label=y_label[1], color=color) ax_c.tick_params(axis="y", labelcolor=color) ax_v.set_xlim(xlim) else: fig, axes = plt.subplots( nrows=number_of_rows, ncols=1, figsize=figsize, sharex=True ) for i in range(number_of_rows): axes[i].plot(raw[x], raw[y[i]]) axes[i].set_ylabel(y_label[i]) axes[0].set_title(title) axes[0].set_xlim(xlim) axes[-1].set_xlabel(x_label) fig.align_ylabels() fig.tight_layout() plt.close(fig) return fig
[docs] def cycle_info_plot( cell, cycle=None, get_axes=False, interactive=True, t_unit="hours", v_unit="V", i_unit="mA", **kwargs, ): """Show raw data together with step and cycle information. Args: cell: cellpy object cycle (int or list or tuple): cycle(s) to select (must be int for matplotlib) get_axes (bool): return axes (for matplotlib) or figure (for plotly) interactive (bool): use interactive plotting (if available) t_unit (str): unit for x-axis (default: "hours") v_unit (str): unit for y-axis (default: "V") i_unit (str): unit for current (default: "mA") **kwargs: parameters specific to plotting backend. Returns: ``matplotlib.axes`` or None """ t_scaler = cell.unit_scaler_from_raw(t_unit, "time") v_scaler = cell.unit_scaler_from_raw(v_unit, "voltage") i_scaler = cell.unit_scaler_from_raw(i_unit, "current") if plotly_available and interactive: fig = _cycle_info_plot_plotly( cell, cycle, get_axes, t_scaler, t_unit, v_scaler, v_unit, i_scaler, i_unit, **kwargs, ) if get_axes: return fig return fig axes = _cycle_info_plot_matplotlib( cell, cycle, get_axes, t_scaler, t_unit, v_scaler, v_unit, i_scaler, i_unit, **kwargs, ) if get_axes: return axes
def _cycle_info_plot_plotly( cell, cycle, get_axes, t_scaler, t_unit, v_scaler, v_unit, i_scaler, i_unit, **kwargs, ): import plotly.express as px import plotly.graph_objects as go import numpy as np if kwargs.get("xlim"): logging.info("xlim is not supported for plotly yet") raw_hdr = get_headers_normal() step_hdr = get_headers_step_table() data = cell.data.raw.copy() table = cell.data.steps.copy() if cycle is None: cycle = list(data[_hdr_raw.cycle_index_txt].unique()) if not isinstance(cycle, (list, tuple)): cycle = [cycle] delta = "_delta" v_delta = step_hdr.voltage + delta i_delta = step_hdr.current + delta c_delta = step_hdr.charge + delta dc_delta = step_hdr.discharge + delta cycle_ = step_hdr.cycle step_ = step_hdr.step type_ = step_hdr.type time_hdr = raw_hdr.test_time_txt cycle_hdr = raw_hdr.cycle_index_txt step_number_hdr = raw_hdr.step_index_txt current_hdr = raw_hdr.current_txt voltage_hdr = raw_hdr.voltage_txt data = data[ [ time_hdr, cycle_hdr, step_number_hdr, current_hdr, voltage_hdr, ] ] table = table[ [ cycle_, step_, type_, v_delta, i_delta, c_delta, dc_delta, ] ] m_cycle_data = data[cycle_hdr].isin(cycle) data = data.loc[m_cycle_data, :] data[time_hdr] = data[time_hdr] * t_scaler data[voltage_hdr] = data[voltage_hdr] * v_scaler data[current_hdr] = data[current_hdr] * i_scaler data = data.merge( table, left_on=(cycle_hdr, step_number_hdr), right_on=(cycle_, step_), ).sort_values(by=[time_hdr]) fig = go.Figure() grouped_data = data.groupby(cycle_hdr) for cycle_number, group in grouped_data: x = group[time_hdr] y = group[voltage_hdr] s = group[step_number_hdr] i = group[current_hdr] st = group[type_] dV = group[v_delta] dI = group[i_delta] dC = group[c_delta] dDC = group[dc_delta] fig.add_trace( go.Scatter( x=x, y=y, mode="lines", name=f"cycle {cycle_number}", customdata=np.stack((i, s, st, dV, dI, dC, dDC), axis=-1), hovertemplate="<br>".join( [ "<b>Time: %{x:.2f}" + f" {t_unit}" + "</b>", " <b>Voltage:</b> %{y:.4f}" + f" {v_unit}", " <b>Current:</b> %{customdata[0]:.4f}" + f" {i_unit}", "<b>Step: %{customdata[1]} (%{customdata[2]})</b>", " <b>ΔV:</b> %{customdata[3]:.2f}", " <b>ΔI:</b> %{customdata[4]:.2f}", " <b>ΔCh:</b> %{customdata[5]:.2f}", " <b>ΔDCh:</b> %{customdata[6]:.2f}", ] ), ), ) cell_name = kwargs.get("title", cell.cell_name) height = kwargs.get("height", 600) width = kwargs.get("width", 1000) title_start = f"<b>{cell_name}</b> Cycle" if len(cycle) > 2: if cycle[-1] - cycle[0] == len(cycle) - 1: title = f"{title_start}s {cycle[0]} - {cycle[-1]}" else: title = f"{title_start}s {cycle}" elif len(cycle) == 2: title = f"{title_start}s {cycle[0]} and {cycle[1]}" else: title = f"{title_start} {cycle[0]}" fig.update_layout( title=title, xaxis_title=f"Time ({t_unit})", yaxis_title=f"Voltage ({v_unit})", width=width, height=height, ) if get_axes: return fig fig.show() def _plot_step(ax, x, y, color): ax.plot(x, y, color=color, linewidth=3) def _get_info(table, cycle, step): m_table = (table[_hdr_steps.cycle] == cycle) & (table[_hdr_steps.step] == step) p1, p2 = table.loc[m_table, ["point_min", "point_max"]].values[0] c1, c2 = table.loc[m_table, ["current_min", "current_max"]].abs().values[0] d_voltage, d_current = table.loc[ m_table, ["voltage_delta", "current_delta"] ].values[0] d_discharge, d_charge = table.loc[ m_table, ["discharge_delta", "charge_delta"] ].values[0] current_max = (c1 + c2) / 2 rate = table.loc[m_table, _hdr_steps.rate_avr].values[0] step_type = table.loc[m_table, _hdr_steps.type].values[0] return [step_type, rate, current_max, d_voltage, d_current, d_discharge, d_charge] def _cycle_info_plot_matplotlib( cell, cycle, get_axes, t_scaler, t_unit, v_scaler, v_unit, i_scaler, i_unit, **kwargs, ): # obs! hard-coded col-names. Please fix me. if cycle is None: warnings.warn("Only one cycle at a time is supported for matplotlib") cycle = 1 if isinstance(cycle, (list, tuple)): warnings.warn("Only one cycle at a time is supported for matplotlib") cycle = cycle[0] data = cell.data.raw table = cell.data.steps span_colors = ["#4682B4", "#FFA07A"] voltage_color = "#008B8B" current_color = "#CD5C5C" m_cycle_data = data[_hdr_raw.cycle_index_txt] == cycle all_steps = data[m_cycle_data][_hdr_raw.step_index_txt].unique() color = itertools.cycle(span_colors) fig = plt.figure(figsize=(20, 8)) fig.suptitle(f"Cycle: {cycle}") ax3 = plt.subplot2grid((8, 3), (0, 0), colspan=3, rowspan=1, fig=fig) # steps ax4 = plt.subplot2grid((8, 3), (1, 0), colspan=3, rowspan=2, fig=fig) # info ax1 = plt.subplot2grid((8, 3), (3, 0), colspan=3, rowspan=5, fig=fig) # data ax2 = ax1.twinx() ax1.set_xlabel(f"time ({t_unit})") ax1.set_ylabel(f"voltage ({v_unit})", color=voltage_color) ax2.set_ylabel(f"current ({i_unit})", color=current_color) annotations_1 = [] # step number (IR) annotations_2 = [] # step number annotations_4 = [] # info for i, s in enumerate(all_steps): m = m_cycle_data & (data[_hdr_raw.step_index_txt] == s) c = data.loc[m, _hdr_raw.current_txt] * i_scaler v = data.loc[m, _hdr_raw.voltage_txt] * v_scaler t = data.loc[m, _hdr_raw.test_time_txt] * t_scaler step_type, rate, current_max, dv, dc, d_discharge, d_charge = _get_info( table, cycle, s ) if len(t) > 1: fcolor = next(color) info_txt = f"{step_type}\ni = |{i_scaler * current_max:0.2f}| {i_unit}\n" info_txt += f"delta V = {dv:0.2f} %\ndelta i = {dc:0.2f} %\n" info_txt += f"delta C = {d_charge:0.2} %\ndelta DC = {d_discharge:0.2} %\n" for ax in [ax2, ax3, ax4]: ax.axvspan(t.iloc[0], t.iloc[-1], facecolor=fcolor, alpha=0.2) _plot_step(ax1, t, v, voltage_color) _plot_step(ax2, t, c, current_color) annotations_1.append([f"{s}", t.mean()]) annotations_4.append([info_txt, t.mean()]) else: info_txt = f"{s}({step_type})" annotations_2.append([info_txt, t.mean()]) ax3.set_ylim(0, 1) for s in annotations_1: ax3.annotate(f"{s[0]}", (s[1], 0.2), ha="center") for s in annotations_2: ax3.annotate(f"{s[0]}", (s[1], 0.6), ha="center") for s in annotations_4: ax4.annotate(f"{s[0]}", (s[1], 0.0), ha="center") for ax in [ax3, ax4]: ax.axes.get_yaxis().set_visible(False) ax.axes.get_xaxis().set_visible(False) if x := kwargs.get("xlim"): ax1.set_xlim(x) ax2.set_xlim(x) ax3.set_xlim(x) ax4.set_xlim(x) if get_axes: return ax1, ax2, ax2, ax4
[docs] @dataclasses.dataclass class CyclesPlotterConfig: """Configuration dataclass for cycles_plot parameters. Encapsulates all parameters for cycles_plot to improve maintainability and enable easier refactoring. Note that 'c' (cellpy object) and 'df' (dataframe) are passed separately as they are data objects, not configuration. """ # Data objects (computed during function execution) form_cycles: Optional[pd.DataFrame] = None rest_cycles: Optional[pd.DataFrame] = None # Plot metadata fig_title: Optional[str] = None capacity_unit: Optional[str] = None # Plotly-specific plotly_template: Optional[str] = None force_colorbar: bool = False force_nonbar: bool = False # Matplotlib-specific figsize: tuple = (6, 4) cbar_aspect: int = 30 # Common styling colormap: str = "Blues_r" formation_colormap: str = "autumn" cut_colorbar: bool = True width: int = 600 height: int = 400 marker_size: int = 5 formation_line_color: str = "rgba(152, 0, 0, .8)" xlim: Optional[list[float]] = None ylim: Optional[list[float]] = None # Cycle information n_rest_cycles: Optional[int] = None n_form_cycles: Optional[int] = None show_formation: bool = True # Seaborn-specific (for matplotlib backend) seaborn_style_dict: Optional[dict] = None seaborn_context: str = "notebook" seaborn_facecolor: str = "#EAEAF2" seaborn_edgecolor: str = "black" seaborn_style: str = "dark" seaborn_palette: str = "deep"
[docs] @notebook_docstring_printer def cycles_plot( c, cycles=None, inter_cycle_shift=True, cycle_mode=None, formation_cycles=3, show_formation=True, mode="gravimetric", method="forth-and-forth", interpolated=True, number_of_points=200, colormap="Blues_r", formation_colormap="autumn", cut_colorbar=True, title=None, figsize=(6, 4), x_range=None, y_range=None, xlim=None, ylim=None, interactive=True, return_figure=None, width=800, height=600, marker_size=5, formation_line_color="rgba(152, 0, 0, .8)", force_colorbar=False, force_nonbar=False, plotly_template=None, seaborn_palette: str = "deep", seaborn_style: str = "dark", return_data=False, **kwargs, ): """ Plot the voltage vs. capacity for different cycles of a cell. This function is meant as an easy way of visualizing the voltage vs. capacity for different cycles of a cell. The cycles are plotted with different colors, and the formation cycles are highlighted with a different colormap. It is not intended to provide you with high quality plots, but rather to give you a quick overview of the data. Args: c: cellpy object containing the data to plot. cycles (list, optional): List of cycle numbers to plot. If None, all cycles are plotted. inter_cycle_shift (bool, optional): Whether to shift the cycles by one. Default is True. cycle_mode (str, optional): Mode for the test (anode or other). Default is None (i.e. use the cellpy cell object's cycle_mode). formation_cycles (int, optional): Number of formation cycles to highlight. Default is 3. show_formation (bool, optional): Whether to show formation cycles. Default is True. mode (str, optional): Mode for capacity ('gravimetric', 'areal', etc.). Default is 'gravimetric'. method (str, optional): Method for interpolation. Default is 'forth-and-forth'. interpolated (bool, optional): Whether to interpolate the data. Default is True. number_of_points (int, optional): Number of points for interpolation. Default is 200. colormap (str, optional): Colormap for the cycles. Default is 'Blues_r'. formation_colormap (str, optional): Colormap for the formation cycles. Default is 'autumn'. cut_colorbar (bool, optional): Whether to cut the colorbar. Default is True. title (str, optional): Title of the plot. If None, the cell name is used. figsize (tuple, optional): Size of the figure for matplotlib. Default is (6, 4). xlim (list, optional): Limits for the x-axis. ylim (list, optional): Limits for the y-axis. interactive (bool, optional): Whether to use interactive plotting (Plotly). Default is True. return_figure (bool, optional): Whether to return the figure object. Default is opposite of interactive. width (int, optional): Width of the figure for Plotly. Default is 600. height (int, optional): Height of the figure for Plotly. Default is 400. marker_size (int, optional): Size of the markers for Plotly. Default is 5. formation_line_color (str, optional): Color for the formation cycle lines in Plotly. Default is 'rgba(152, 0, 0, .8)'. force_colorbar (bool, optional): Whether to force the colorbar to be shown. Default is False. force_nonbar (bool, optional): Whether to force the colorbar to be hidden. Default is False. plotly_template (str, optional): Plotly template to use (uses default template if None). seaborn_palette: name of the seaborn palette to use (only if seaborn is available). seaborn_style: name of the seaborn style to use (only if seaborn is available). return_data (bool, optional): Whether to return the data used for the plot. Default is False. **kwargs: Additional keyword arguments for the plotting backend. Additional keyword arguments for Plotly: plotly_max_individual_traces_for_lines (int, optional): Maximum number of individual traces (not including formation cycles) for lines in Plotly. Default is 8. plotly_xaxes_kwargs (dict, optional): propagated to plotly.update_xaxes. plotly_yaxes_kwargs (dict, optional): propagated to plotly.update_yaxes. plotly_layout_kwargs (dict, optional): propagated to plotly.update_layout. Returns: The figure is a matplotlib.figure.Figure or a plotly.graph_objects.Figure, depending on the backend used. If return_data is True: tuple: (figure, data) If return_figure is True: figure: The generated plot figure (same as the return value). Else: None: The plot is shown in the default browser. """ if interactive and not plotly_available: warnings.warn("Can not perform interactive plotting. Plotly is not available.") interactive = False if return_figure is None: return_figure = not interactive if cycles is None: cycles = c.get_cycle_numbers() if title is None: _bold = "<b>" if interactive else "'" _end_bold = "</b>" if interactive else "'" _newline = "<br>" if interactive else "\n" _small = '<span style="font-size: 14px;">' if interactive else "" _end_small = "</span>" if interactive else "" top_title_line = f"Capacity plots for {_bold}{c.cell_name}{_end_bold}" second_title_line = f"{_small} - {mode} mode" if interpolated: second_title_line = f"{second_title_line}, interpolated ({number_of_points} points){_end_small}" else: second_title_line = f"{second_title_line}{_end_small}" title = _newline.join([top_title_line, second_title_line]) kw_arguments = dict( method=method, interpolated=interpolated, label_cycle_number=True, categorical_column=True, number_of_points=number_of_points, insert_nan=True, mode=mode, cycle_mode=cycle_mode, inter_cycle_shift=inter_cycle_shift, ) df = c.get_cap(cycles=cycles, **kw_arguments) # Temporary fix to ensure that the cycles are plotted in the correct order: df = df.sort_values(by=["cycle", "direction"]) selector = df["cycle"] <= formation_cycles form_cycles = df.loc[selector, :] rest_cycles = df.loc[~selector, :] n_form_cycles = len(form_cycles["cycle"].unique()) n_rest_cycles = len(rest_cycles["cycle"].unique()) capacity_unit = _get_capacity_unit(c, mode=mode) # Preparing for more homogeneous parameters: if x_range is not None: xlim = x_range if y_range is not None: ylim = y_range # Extracting seaborn-specific parameters from kwargs (for matplotlib backend): seaborn_context = kwargs.pop("seaborn_context", "notebook") seaborn_facecolor = kwargs.pop("seaborn_facecolor", "#EAEAF2") seaborn_edgecolor = kwargs.pop("seaborn_edgecolor", "black") seaborn_style_dict = kwargs.pop("seaborn_style_dict", None) config = CyclesPlotterConfig( form_cycles=form_cycles, rest_cycles=rest_cycles, fig_title=title, capacity_unit=capacity_unit, plotly_template=plotly_template, colormap=colormap, formation_colormap=formation_colormap, cut_colorbar=cut_colorbar, cbar_aspect=30, figsize=figsize, force_colorbar=force_colorbar, force_nonbar=force_nonbar, n_rest_cycles=n_rest_cycles, n_form_cycles=n_form_cycles, show_formation=show_formation, width=width, height=height, marker_size=marker_size, formation_line_color=formation_line_color, xlim=xlim, ylim=ylim, seaborn_style=seaborn_style, seaborn_palette=seaborn_palette, seaborn_context=seaborn_context, seaborn_facecolor=seaborn_facecolor, seaborn_edgecolor=seaborn_edgecolor, seaborn_style_dict=seaborn_style_dict, ) if interactive: fig = _cycles_plotter_plotly(c, df, config, **kwargs) if return_data: return fig, df elif return_figure: return fig else: fig.show() else: fig = _cycles_plotter_matplotlib(c, df, config, **kwargs) if return_figure or return_data: plt.close(fig) if return_data: return fig, df elif return_figure: return fig
def _cycles_plotter_matplotlib( c, df, config: CyclesPlotterConfig, **kwargs, ): import numpy as np import matplotlib from matplotlib.colors import Normalize, ListedColormap if seaborn_available: import seaborn as sns seaborn_style_dict = config.seaborn_style_dict or { "axes.facecolor": config.seaborn_facecolor, "axes.edgecolor": config.seaborn_edgecolor, } sns.set_style(config.seaborn_style, seaborn_style_dict) sns.set_palette(config.seaborn_palette) sns.set_context(config.seaborn_context) fig, ax = plt.subplots(1, 1, figsize=config.figsize) fig_width, fig_height = config.figsize if not config.form_cycles.empty and config.show_formation: if fig_width < 6: logging.critical( "Warning: try setting the figsize to (6, 4) or larger for better visualization" ) if fig_width > 8: logging.critical( "Warning: try setting the figsize to (8, 4) or smaller for better visualization" ) min_cycle, max_cycle = ( config.form_cycles["cycle"].min(), config.form_cycles["cycle"].max(), ) norm_formation = Normalize(vmin=min_cycle, vmax=max_cycle) cycle_sequence = np.arange(min_cycle, max_cycle + 1, 1) shrink = min(1.0, (1 / 8) * config.n_form_cycles) c_m_formation = ListedColormap( plt.get_cmap(config.formation_colormap, 2 * len(cycle_sequence))( cycle_sequence ) ) s_m_formation = matplotlib.cm.ScalarMappable( cmap=c_m_formation, norm=norm_formation ) for name, group in config.form_cycles.groupby("cycle"): ax.plot( group["capacity"], group["voltage"], lw=2, # alpha=0.7, color=s_m_formation.to_rgba(name), label=f"Cycle {name}", ) cbar_formation = fig.colorbar( s_m_formation, ax=ax, # label="Formation Cycle", ticks=np.arange( config.form_cycles["cycle"].min(), config.form_cycles["cycle"].max() + 1, 1, ), shrink=shrink, aspect=config.cbar_aspect * shrink, location="right", anchor=(0.0, 0.0), ) cbar_formation.set_label( "Form. Cycle", rotation=270, labelpad=12, ) norm = Normalize( vmin=config.rest_cycles["cycle"].min(), vmax=config.rest_cycles["cycle"].max() ) if config.cut_colorbar: cycle_sequence = np.arange( config.rest_cycles["cycle"].min(), config.rest_cycles["cycle"].max() + 1, 1 ) n = int(np.round(1.2 * config.rest_cycles["cycle"].max())) c_m = ListedColormap(plt.get_cmap(config.colormap, n)(cycle_sequence)) else: c_m = plt.get_cmap(config.colormap) s_m = matplotlib.cm.ScalarMappable(cmap=c_m, norm=norm) for name, group in config.rest_cycles.groupby("cycle"): ax.plot( group["capacity"], group["voltage"], lw=1, color=s_m.to_rgba(name), label=f"Cycle {name}", ) cbar = fig.colorbar( s_m, ax=ax, label="Cycle", aspect=config.cbar_aspect, location="right", ) cbar.set_label( "Cycle", rotation=270, labelpad=12, ) # cbar.ax.yaxis.set_ticks_position("left") ax.set_xlabel(f"Capacity ({config.capacity_unit})") ax.set_ylabel(f"Voltage ({c.cellpy_units.voltage})") ax.set_title(config.fig_title, loc="left", wrap=True) fig.tight_layout() if config.xlim: ax.set_xlim(config.xlim) if config.ylim: ax.set_ylim(config.ylim) return fig def _cycles_plotter_plotly( c, df, config: CyclesPlotterConfig, **kwargs, ): import plotly.express as px import plotly.graph_objects as go set_plotly_template(config.plotly_template) color_scales = px.colors.named_colorscales() plotly_max_individual_traces_for_lines = kwargs.pop("plotly_max_individual_traces_for_lines", 8) if config.colormap not in color_scales: colormap = "Blues_r" else: colormap = config.colormap if config.cut_colorbar: range_color = [df["cycle"].min(), 1.2 * df["cycle"].max()] else: range_color = [df["cycle"].min(), df["cycle"].max()] if ( config.n_rest_cycles is not None and config.n_rest_cycles < plotly_max_individual_traces_for_lines and not config.force_colorbar ) or config.force_nonbar: logging.info("using px.line for non-formation cycles") show_formation_legend = True cmap = px.colors.sample_colorscale( colorscale=colormap, samplepoints=config.n_rest_cycles, low=0.0, high=0.8, colortype="rgb", ) fig = px.line( config.rest_cycles, x="capacity", y="voltage", color="cycle", title=config.fig_title, labels={ "capacity": f"Capacity ({config.capacity_unit})", "voltage": f"Voltage ({c.cellpy_units.voltage})", }, color_discrete_sequence=cmap, ) else: logging.info("using px.scatter for non-formation cycles") show_formation_legend = False fig = px.scatter( config.rest_cycles, x="capacity", y="voltage", title=config.fig_title, color="cycle", labels={ "capacity": f"Capacity ({config.capacity_unit})", "voltage": f"Voltage ({c.cellpy_units.voltage})", }, color_continuous_scale=colormap, range_color=range_color, ) fig.update_traces(mode="lines+markers", line_color="white", line_width=1) if not config.form_cycles.empty and config.show_formation: for name, group in config.form_cycles.groupby("cycle"): logging.info(f"using go.Scatter for formation cycle(s) {name}") trace = go.Scatter( x=group["capacity"], y=group["voltage"], name=f"{name} (f.c.)", hovertemplate=f"Formation Cycle {name}<br>Capacity: %{{x}}<br>Voltage: %{{y}}", mode="lines", marker=dict(color=config.formation_line_color), showlegend=show_formation_legend, legendrank=1, legendgroup="formation", ) fig.add_trace(trace) fig.update_traces(marker=dict(size=config.marker_size)) if config.xlim: fig.update_xaxes(range=config.xlim) if config.ylim: fig.update_yaxes(range=config.ylim) plotly_xaxes_kwargs = kwargs.pop("plotly_xaxes_kwargs", {}) plotly_yaxes_kwargs = kwargs.pop("plotly_yaxes_kwargs", {}) if plotly_xaxes_kwargs: fig.update_xaxes(**plotly_xaxes_kwargs) if plotly_yaxes_kwargs: fig.update_yaxes(**plotly_yaxes_kwargs) plotly_layout_kwargs = kwargs.pop("plotly_layout_kwargs", {}) fig.update_layout( height=config.height, width=config.width, **plotly_layout_kwargs, ) return fig def _cell_and_output_path(): import pathlib import cellpy this_file = pathlib.Path(__file__) # p = this_file.parent.parent.parent / "testdata/hdf5/20160805_test001_45_cc.h5" p = pathlib.Path(r"C:\scripting\cellpy\local\20240516_nor000_01_fccc_01.h5") out = this_file.parent.parent.parent / "tmp" print(f"{p=}") print(f"{out=}") print(f"{p.exists()=}") print(f"{out.exists()=}") # c = cellpy.get(p) c = cellpy.get( p, instrument="arbin_sql_h5", cycle_mode="fullcell", mass=15.5, area=1.767, loading=8.8, nominal_capacity=150.0) return c, out def _check_plotter_plotly(): import pathlib import cellpy p = pathlib.Path("../../testdata/hdf5/20160805_test001_45_cc.h5") out = pathlib.Path("../../tmp") assert out.exists() c = cellpy.get(p) fig = cycles_plot( c, ylim=[0.0, 1.0], show_formation=False, cut_colorbar=False, title="My nice plot", interactive=True, return_figure=True, ) print("saving figure") print(f"{fig=}") print(f"{type(fig)=}") save_image_files(fig, out / "test_plot_plotly", backend="plotly") fig.show() def _check_plotter_matplotlib(): import matplotlib.pyplot as plt import seaborn as sns import pathlib import cellpy p = pathlib.Path("../../testdata/hdf5/20160805_test001_45_cc.h5") out = pathlib.Path("../../tmp") assert out.exists() c = cellpy.get(p) fig = cycles_plot( c, ylim=[0.0, 1.0], show_formation=False, cut_colorbar=False, title="My nice plot", interactive=False, return_figure=True, ) print("saving figure") print(f"{fig=}") print(f"{type(fig)=}") save_image_files(fig, out / "test_plot_matplotlib", backend="matplotlib") # need to create a new manager to show the figure since it is closed in # the plot_cycles function when issuing return_figure=True: make_matplotlib_manager(fig) plt.show() def _check_summary_plotter_plotly(): c, out = _cell_and_output_path() print("Checking summary_plotter_plotly") fig = summary_plot( c, # x="normalized_cycle_index", y="fullcell_standard_gravimetric", fullcell_standard_normalization_type="on-cycles", # fullcell_standard_normalization_factor=1500.0, fullcell_standard_normalization_cycle_numbers=[18], # ce_range=[0.0, 200.0], # ylim=[0.0, 1.0], # show_formation=False, # cut_colorbar=False, # split=True, title="My nice plot", interactive=True, # rangeslider=True, show_formation=True, # return_data=False, ) # print("saving figure") # print(f"{fig=}") # print(f"{type(fig)=}") # save_image_files(fig, out / "test_plot_plotly", backend="plotly") fig.show(renderer="browser") print("DONE") def _check_summary_plotter_seaborn(): import matplotlib matplotlib.use("Agg") print("Checking summary_plotter_seaborn") c, out = _cell_and_output_path() # Set non-interactive backend for VS Code/Cursor compatibility fig = summary_plot( c, # x="normalized_cycle_index", # y="capacities_gravimetric_split_constant_voltage", y="fullcell_standard_gravimetric", fullcell_standard_normalization_type="on-cycles", # fullcell_standard_normalization_factor=1500.0, fullcell_standard_normalization_cycle_numbers=[18], # ce_range=[0.0, 200.0], # ylim=[0.0, 1.0], # show_formation=False, # cut_colorbar=False, # split=True, title="My nice plot", interactive=False, # rangeslider=True, show_formation=True, # return_figure=True, ) # print("saving figure") # print(f"{fig=}") # print(f"{type(fig)=}") # save_image_files(fig, out / "test_plot_plotly", backend="plotly") # Note: In VS Code/Cursor, use save_image_files instead of show() # fig.figure.show() # This doesn't work in VS Code/Cursor save_image_files(fig, out / "test_plot_seaborn", backend="seaborn") print("DONE") def _check_cycles_plotter_plotly(): c, out = _cell_and_output_path() print("Checking cycle_plotter_plotly") fig = cycles_plot( c, y="capacities_gravimetric", cycles=[1, 2, 3, 4, 5, 20, 40, 60], interactive=True, return_figure=True, ) save_image_files(fig, out / "test_plot_cycles_plotly", backend="plotly") print("DONE") def _check_cycles_plotter_matplotlib(): import matplotlib matplotlib.use("Agg") c, out = _cell_and_output_path() print("Checking cycle_plotter_matplotlib") fig = cycles_plot( c, y="capacities_gravimetric", interactive=False, return_figure=True, ) save_image_files(fig, out / "test_plot_cycles_matplotlib", backend="matplotlib") print("DONE") if __name__ == "__main__": # _check_plotter_plotly() # _check_plotter_matplotlib() # _check_summary_plotter_plotly() _check_summary_plotter_seaborn() # _check_cycles_plotter_plotly() # _check_cycles_plotter_matplotlib()