diff --git a/.gitignore b/.gitignore index ef3b63a..c70fa92 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,8 @@ share/python-wheels/ pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports +# Unit test / coverage reports / in progress +inprogress/ references/ reports/ htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1bd28..1a06d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,17 @@ ## [Unreleased](https://github.com/NatLabRockies/ampworks) ### New Features +- New `interactive_plotly` and `interactive_bokeh` methods with `kind` line/scatter option ([#31](https://github.com/NatLabRockies/ampworks/pull/31)) - New hidden `auxiliary` module for repeated logic across package (only for devs, for now) ([#30](https://github.com/NatLabRockies/ampworks/pull/30)) +### Deprecations +- `interactive_xy_plot` deprecated in favor of `interactive_plotly` ([#31](https://github.com/NatLabRockies/ampworks/pull/31)) + ### Optimizations None. ### Bug Fixes -None. +- Extra tabs in some `.txt` files were creating read errors, now ignore ragged columns ([#31](https://github.com/NatLabRockies/ampworks/pull/31)) ### Chores - Use base class `_RangeLabel` for both cycle and section labels ([#29](https://github.com/NatLabRockies/ampworks/pull/29)) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1de1270..3665699 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -146,11 +146,12 @@ # to keep in the ampworks documentation. The rest is excluded. dataset_keep = [ - 'zero_time', - 'zero_below', 'downsample', 'enforce_monotonic', - 'interactive_xy_plot', + 'interactive_plotly', + 'interactive_bokeh', + 'zero_below', + 'zero_time', ] richres_keep = ['copy'] diff --git a/pyproject.toml b/pyproject.toml index 6f2ce37..bb7d787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,11 @@ build-backend = "setuptools.build_meta" name = "ampworks" readme = "README.md" dynamic = ["version"] -description = "Processing and visualization tools for battery experiments." -keywords = ["battery", "data", "analysis", "ICA", "dQdV", "GITT", "ICI"] +description = "Battery data analysis and visualization tools." +keywords = [ + "battery", "data", "analysis", "model", "degradation", "ecm", "spm", "p2d", + "parameters", "visualization", "dqdv", "gitt", "ici", "hppc", "ocv", +] requires-python = ">=3.10,<3.15" license = "BSD-3-Clause" license-files = ["LICENSE"] @@ -33,9 +36,11 @@ dependencies = [ "tqdm", "xlrd", "numpy", + "bokeh", "pandas", "polars", "plotly", + "IPython", "pyarrow", "seaborn", "openpyxl", diff --git a/src/ampworks/_auxiliary.py b/src/ampworks/_auxiliary.py index 03f5940..ebad164 100644 --- a/src/ampworks/_auxiliary.py +++ b/src/ampworks/_auxiliary.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from scipy.integrate import cumulative_trapezoid @@ -78,7 +78,7 @@ def _calc_soc(data: amp.Dataset, charging: bool | None = None) -> None: def _calc_relative_time( data: amp.Dataset, - groupby_cols: str | Sequence[str], + groupby_cols: str | list[str], col_name: str = 'RelativeTime', ) -> None: """ @@ -93,7 +93,7 @@ def _calc_relative_time( ---------- data : Dataset DataFrame with a 'Seconds' column, and columns needed for grouping. - groupby_cols : str or Sequence[str] + groupby_cols : str or list[str] Column names to group by before computing relative time. col_name : str, optional Name of the output column. Defaults to `'RelativeTime'`. diff --git a/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index dd46e22..98c1484 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -1,9 +1,14 @@ from __future__ import annotations +from typing import Literal + import numpy as np import pandas as pd import plotly.express as px +from bokeh.plotting import figure +from bokeh.models import HoverTool, ColumnDataSource + class Dataset(pd.DataFrame): """General dataset.""" @@ -23,11 +28,11 @@ def downsample( keep_last: bool = False, ) -> Dataset | None: """ - Downsample the dataset by eliminating rows using one of the following: + Downsample the dataset by eliminating rows given: - - Keep a given number of rows - - Keep a given fraction of rows - - Keep rows based on the resolution of a given column + - number of rows + - fraction of rows + - resolution of a specified column Parameters ---------- @@ -37,7 +42,7 @@ def downsample( Fraction (in (0, 1]) of evenly spaced rows to keep, by default None. resolution : tuple[str, float], optional Column (str) and resolution (float) to use for downsampling based on - adjacent values. By default None. + the absolute difference between adjacent values. By default None. inplace : bool, optional Modify in place if True. If False (default), return a new Dataset. ignore_index : bool, optional @@ -58,10 +63,8 @@ def downsample( Examples -------- - Below are examples of how to use the downsample method. In the first two - examples, the rows are dropped evenly across the dataset. In the third - example, rows are dropped based on the resolution of the 'Volts' column, - ensuring that adjacent voltage readings are at least 1 mV apart. + The following demonstrates three ways to downsample a dataset. Note that + only the `resolution` option requires a column to operate on. .. code-block:: python @@ -233,17 +236,19 @@ def enforce_monotonic( else: return result - def interactive_xy_plot( - self, x: str, y: str, tips: list[str] | None = None, - figsize: tuple[int, int] = (800, 450), save: str = None, + def interactive_plotly( + self, + x: str, + y: str, + *, + tips: list[str] | None = None, + figsize: tuple[int | None, int | None] = (800, 450), + kind: Literal['line', 'scatter', 'both'] = 'line', + save: str = None, ) -> None: """ - Create an interactive XY plot using Plotly. Allows hovertips, zooming, - and more. Optionally, save the plot to an html file, which can be sent - and opened in a web browser, without needing Python and/or ampworks. - - The hovertips are particularly useful for exploring the data and finding - specific cycle and steps for slicing and further analysis. + Create an interactive plotly figure with hover tips. Optionally save as + a standalone HTML file, viewable without installing Python/ampworks. Parameters ---------- @@ -253,54 +258,207 @@ def interactive_xy_plot( Column name for the variable to plot on the y-axis. tips : list[str] or None, optional List of column names to display as hover tips, by default None. - figsize : tuple[int, int], optional - Figure size (width, height) in pixels, by default (800, 450). + figsize : tuple[int | None, int | None], optional + Figure size (width, height) in pixels, by default (800, 450). Set + either or both dimensions to None to allow them to stretch. + kind : {'line', 'scatter', 'both'}, optional + Kind of plot to draw. 'line' (default) for a line plot, 'scatter' + for a scatter plot, or 'both' to show both a line and markers. save : str, optional File path to save the plot as an HTML file, by default None. + See Also + -------- + interactive_bokeh + Interactive plots using bokeh. Typically has higher performance for + large (>250k) datasets and better support for notebook exports. + Notes ----- - When run inside a Jupyter notebook, the plot will be rendered inline. If - instead this function is called from a script, the plot will be saved to - a temporary directory and automatically opened in a local web browser. + The responsive height size option is limited in notebook environments + since output cells do not have adjustable heights. In these cases, the + height is set to a default minimum value. Examples -------- - The following example uses the 'hppc_discharge' dataset and creates an - interactive XY plot of 'Seconds' vs. 'Volts', with a hover tip showing - the step number. Even though only one hover tip is requested, it must - be passed in a list. For more than one hover tip, simply add more column - names to the list. + The following creates an interactive plot of an HPPC discharge dataset. + Note that the x, y, and tips values must be existing columns; however, + you can compute or add new columns before plotting, if needed, as shown + by adding an 'Hours' column in the second figure below. Also, hovertips + must be passed as a list, even if only one column is requested. + + .. code-block:: python + + import ampworks as amp + + data = amp.datasets.load_datasets('hppc/hppc_discharge') + data.interactive_plotly('Seconds', 'Volts', tips=['Step']) + + # Add new column to plot time in hours instead of seconds + data['Hours'] = data['Seconds'] / 3600 + data.interactive_plotly('Hours', 'Volts', tips=['Step', 'Amps']) + + """ + from ampworks.plotutils._plotly import ( + _apply_plotly_style, _render_plotly, + ) + + hover = {} if tips is None else {col: True for col in tips} + + kind = kind.lower() + + if kind in ['line', 'both']: + markers = True if kind == 'both' else False + fig = px.line(self, x=x, y=y, markers=markers, hover_data=hover) + elif kind == 'scatter': + fig = px.scatter(self, x=x, y=y, hover_data=hover) + else: + raise ValueError( + "Invalid value for 'kind'. Expected one of {'line', 'scatter'," + " 'both'}, but got " + f"{kind=}." + ) + + _apply_plotly_style(fig) + _render_plotly(fig=fig, figsize=figsize, save=save) + + def interactive_bokeh( + self, + x: str, + y: str, + *, + tips: list[str] | None = None, + figsize: tuple[int | None, int | None] = (800, 450), + kind: Literal['line', 'scatter', 'both'] = 'line', + save: str = None, + ) -> None: + """ + Create an interactive bokeh figure with hover tips. Optionally save as + a standalone HTML file, viewable without installing Python/ampworks. + + Parameters + ---------- + x : str + Column name for the variable to plot on the x-axis. + y : str + Column name for the variable to plot on the y-axis. + tips : list[str] or None, optional + List of column names to display as hover tips, by default None. + figsize : tuple[int | None, int | None], optional + Figure size (width, height) in pixels, by default (800, 450). Set + either or both dimensions to None to allow them to stretch. + kind : {'line', 'scatter', 'both'}, optional + Type of plot to create. 'line' (default) for a line plot, 'scatter' + for a scatter plot, or 'both' to show both a line and markers. + save : str, optional + File path to save the plot as an HTML file, by default None. + + See Also + -------- + interactive_plotly + Interactive plots using plotly. Typically has lower performance for + large (>250k) datasets, but is compatible with `dash` apps. + + Notes + ----- + The responsive height size option is limited in notebook environments + since output cells do not have adjustable heights. In these cases, the + height is set to a default minimum value. - The interactive plots only allow one x and one y variable, and both are - required to be existing columns in the dataset. In the second example, - we compute a new column for time in hours so that we can change the - x-axis to 'Hours' instead of 'Seconds'. + Examples + -------- + The following creates an interactive plot of an HPPC discharge dataset. + Note that the x, y, and tips values must be existing columns; however, + you can compute or add new columns before plotting, if needed, as shown + by adding an 'Hours' column in the second figure below. Also, hovertips + must be passed as a list, even if only one column is requested. .. code-block:: python import ampworks as amp data = amp.datasets.load_datasets('hppc/hppc_discharge') - data.interactive_xy_plot('Seconds', 'Volts', tips=['Step']) + data.interactive_bokeh('Seconds', 'Volts', tips=['Step']) # Add new column to plot time in hours instead of seconds data['Hours'] = data['Seconds'] / 3600 - data.interactive_xy_plot('Hours', 'Volts', tips=['Step']) + data.interactive_bokeh('Hours', 'Volts', tips=['Step', 'Amps']) """ - from ampworks.plotutils._plotly import PLOTLY_TEMPLATE, _render_plotly + from ampworks.plotutils._bokeh import ( + BOKEH_CONFIG, _apply_bokeh_style, _render_bokeh, + ) if tips is None: tips = [] - fig = px.line( - self, x=x, y=y, markers=True, - hover_data={col: True for col in tips}, + kind = kind.lower() + + color = '#636EFA' # adopt color from plotly's default + + cols = [x, y] + tips + source = ColumnDataSource(data=self[cols]) + + # Horizontal HTML tooltip to match Plotly's compact single-row layout + tooltips = [(x, '$x'), (y, '$y')] + for tip in tips: + tooltips.append((tip, "@{" + tip + "}")) + + fig = figure( + x_axis_label=x, + y_axis_label=y, + width=figsize[0], + height=figsize[1], + **BOKEH_CONFIG, ) - fig.update_layout(template=PLOTLY_TEMPLATE) - _render_plotly(fig=fig, figsize=figsize, save=save) + if kind not in ['line', 'scatter', 'both']: + raise ValueError( + "Invalid value for 'kind'. Expected one of {'line', 'scatter'," + " 'both'}, but got " + f"{kind=}." + ) + + line = fig.line(x=x, y=y, source=source, color=color, line_width=2) + + # hide line if only scatter is requested, done for hover tool, to reduce + # too many points showing when dense or overlapping (discussed below) + if kind == 'scatter': + line.glyph.line_alpha = 0 + + if kind in ['scatter', 'both']: + fig.scatter(x=x, y=y, source=source, color=color, size=4.5) + + # Attach hover only to the line so a single tooltip fires even when + # markers are densely overlapping at zoomed-out views + hover = HoverTool(mode='vline', renderers=[line], tooltips=tooltips) + fig.add_tools(hover) + + _apply_bokeh_style(fig) + _render_bokeh(fig=fig, figsize=figsize, save=save) + + def interactive_xy_plot( + self, + x: str, + y: str, + *, + tips: list[str] | None = None, + figsize: tuple[int | None, int | None] = (800, 450), + save: str = None, + ) -> None: + """ + Deprecated. This method will be removed in a future release. Use either + `interactive_plotly` or `interactive_bokeh` instead. + + """ + import warnings + warnings.warn( + "interactive_xy_plot() is deprecated and will be removed in a" + " future release. Use interactive_plotly() or interactive_bokeh().", + DeprecationWarning, + stacklevel=2, + ) + self.interactive_plotly( + x=x, y=y, tips=tips, figsize=figsize, kind='both', save=save, + ) def zero_below( self, @@ -316,11 +474,10 @@ def zero_below( column : str Column name to apply thresholding. threshold : float - Values with absolute value below this threshold are set to zero. - Note that values exactly equal to the threshold are not zeroed. + Values whose absolute value is below this threshold are set to zero. + Note that values equal to the threshold are not zeroed. inplace : bool, optional - If True, modify the Dataset in place. Otherwise, return a new - Dataset. Default is False. + Modify in place if True. If False (default), return a new Dataset. Returns ------- @@ -329,15 +486,11 @@ def zero_below( Examples -------- - Occasionally, there may be small non-zero values in the data that can - be considered as noise and set to zero. When not appropriately zeroed, - these can cause issues with automatic pulse detection (i.e., where the - algorithm detects changes from rests to non-rests and vice versa). So, - in the following example, we load the 'hppc_discharge' dataset and zero - out current values below a certain threshold. The threshold here is set - to 1% of the mean current from non-rest data, however, the appropriate - threshold should be determined based on the specific characteristics of - the dataset. + Small non-zero values that can be attributed to noise can interfere with + some analysis methods. For example, automatic pulse detection identifies + pulses based on transitions from zero to non-zero current. This example + filters out currents below 1% of the mean non-rest current, though the + thresholds should be tailored to your specific use case and data. .. code-block:: python @@ -361,14 +514,13 @@ def zero_below( def zero_time(self, inplace: bool = False) -> Dataset | None: """ - Shift the `Seconds` column by subtracting the value in the first row, - creating a new zero time reference. + Shifts the `Seconds` column by subtracting the first row's value to set + a new zero reference. Parameters ---------- inplace : bool, optional - If True, modify the Dataset in place. Otherwise, return a new - Dataset. Default is False. + Modify in place if True. If False (default), return a new Dataset. Returns ------- diff --git a/src/ampworks/_core/_headers.py b/src/ampworks/_core/_headers.py index 7debf52..180083a 100644 --- a/src/ampworks/_core/_headers.py +++ b/src/ampworks/_core/_headers.py @@ -58,8 +58,8 @@ def strip_chars(string: str | list[str] | None) -> str | list[str] | None: if isinstance(string, list): return [strip_chars(s) for s in string] - transmap = str.maketrans('(/,', '...', ' _-#<>)') - return string.lower().translate(transmap) + transmap = str.maketrans('[(/,', '....', ' _-#<>)]') + return string.lower().translate(transmap).replace('..', '.').strip('.') t_names = ['t', 'time', 'testtime', 'totaltime'] @@ -71,10 +71,10 @@ def strip_chars(string: str | list[str] | None) -> str | list[str] | None: v_names = ['voltage', 'potential', 'ecell'] v_units = ['v', 'volts'] -q_names = ['capacity', 'amphours'] +q_names = ['capacity', 'amphours', 'cap'] q_units = ['ah', 'ahr', 'amphr', 'mah', 'mahr', 'mamphr'] -e_names = ['energy', 'watthours'] +e_names = ['energy', 'watthours', 'ener'] e_units = ['wh', 'whr', 'watthr'] HEADER_ALIASES = { diff --git a/src/ampworks/_core/_read.py b/src/ampworks/_core/_read.py index 4b137da..49ca46f 100644 --- a/src/ampworks/_core/_read.py +++ b/src/ampworks/_core/_read.py @@ -56,7 +56,11 @@ def _read_delimited( _check_type('aliases', aliases, HeaderAliases) - options = {'separator': delimiter, 'ignore_errors': True} + options = { + 'ignore_errors': True, + 'separator': delimiter, + 'truncate_ragged_lines': True, + } skip_rows = None with open(filepath, encoding='latin1') as datafile: diff --git a/src/ampworks/gitt/_extract_params.py b/src/ampworks/gitt/_extract_params.py index e13d07b..98e8169 100644 --- a/src/ampworks/gitt/_extract_params.py +++ b/src/ampworks/gitt/_extract_params.py @@ -12,7 +12,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, - tmax: float = 60, return_all: bool = False) -> pd.DataFrame: + tmax: float = 60, return_stats: bool = False) -> Dataset: """ Extracts parameters from GITT data. @@ -25,17 +25,19 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, The following protocol was used to test this algorithm: 1. Rest for 5 min, log data every 10 s. - - 2. Charge (or discharge) at C/20 for 11 min; include a voltage limit. Log - every 0.2 s or every 5 mV. - + 2. Charge at C/20 for 11 min; with a voltage limit. Log every 0.2 s or 5 mV. 3. Rest for 135 min, log data every 10 min or every 5 mV. - 4. Stop if voltage limit reached in (2), otherwise repeat (2) and (3). The protocol assumes formation cycles have already been completed and that the cell was rested until equilibrium before starting the steps above. - Implementation details are available in [1]_. + Implementation details are available in [1]_. This specific protocol assumes + the cell starts at a fully discharged state and only includes charge pulses; + however, you can similarly perform the experiment in the discharge direction + or in both directions. The only change would be to step (2) where you would + discharge at C/20 instead of charge. It is common to perform the GITT tests + in both directions, but you must process the charge and discharge segments + separately by slicing your data and calling this routine twice. Parameters ---------- @@ -51,17 +53,17 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, tmax : float, optional The maximum relative pulse time (in seconds) to use when fitting sqrt(t) vs. voltage for time constants. Default is 60. See notes for more info. - return_all : bool, optional + return_stats : bool, optional If False (default), only the extracted parameters vs. state of charge are returned. If True, also returns stats with info about each pulse. Returns ------- - params : pd.DataFrame + params : Dataset Table of parameters. Columns include 'SOC' (state of charge, -), 'Ds' (diffusivity, m2/s), and 'Eeq' (equilibrium potential, V). - stats : pd.DataFrame - Only returned if `return_all=True`. Provides additional stats about + stats : Dataset + Only returned if `return_stats=True`. Provides additional stats about each pulse, including errors from the sqrt(t) vs. voltage regressions. Raises @@ -104,13 +106,15 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, -------- >>> import ampworks as amp >>> data = amp.datasets.load_datasets('gitt/gitt_discharge') - >>> params, stats = amp.gitt.extract_params(data, 1.8e-6, return_all=True) + >>> params, stats = amp.gitt.extract_params(data, 1.8e-6, return_stats=True) >>> params.plot('SOC', 'Eeq') >>> params.plot('SOC', 'Ds', logy=True) >>> print(params) >>> print(stats) """ + import ampworks as amp + from ampworks._checks import _check_columns, _check_only_one from ampworks._auxiliary import _infer_state, _calc_soc, _calc_relative_time @@ -124,32 +128,32 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, message="'data' cannot include both charge and discharge segments.", ) - df = data.copy() - df = df.reset_index(drop=True) + ds = data.copy() + ds = ds.reset_index(drop=True) # States based on current direction: charge, discharge, or rests - _infer_state(df) + _infer_state(ds) # Add in state-of-charge column to map each value to an SOC - _calc_soc(df, charging) + _calc_soc(ds, charging) # Count each time a rest/charge or rest/discharge changeover occurs - pulse = (df['State'] != 'R') & (df['State'].shift(fill_value='R') == 'R') - df['Pulse'] = pulse.cumsum() + pulse = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Pulse'] = pulse.cumsum() # Relative time of each rest/charge or rest/discharge step - _calc_relative_time(df, ['Pulse', 'State'], col_name='StepTime') + _calc_relative_time(ds, ['Pulse', 'State'], col_name='StepTime') # Remove last cycle if not complete, i.e., ended on charge or discharge - if df.iloc[-1]['State'] != 'R': - df = df[df['Pulse'] != df['Pulse'].max()].reset_index(drop=True) + if ds.iloc[-1]['State'] != 'R': + ds = ds[ds['Pulse'] != ds['Pulse'].max()].reset_index(drop=True) # Record summary stats for each loop, immediately before the pulses - groups = df[df['State'] != 'R'].groupby('Pulse', as_index=False) + groups = ds[ds['State'] != 'R'].groupby('Pulse', as_index=False) summary = groups.agg(lambda x: x.iloc[0]) # Store slope and intercepts (V = m*t^0.5 + b) for each pulse - groups = df.groupby('Pulse') + groups = ds.groupby('Pulse') regression = None for idx, g in groups: @@ -189,7 +193,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, stats = pd.merge(summary, regression, on='Pulse') stats['dEdt'] = np.gradient(stats['Volts'], np.cumsum(stats['dt_pulse'])) - params = pd.DataFrame({ + params = amp.Dataset({ 'SOC': stats['SOC'], 'Ds': 4./9./np.pi * (radius * stats['dEdt']/stats['dUdrt'])**2, 'Eeq': stats['Eeq'], @@ -197,7 +201,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, params.sort_values(by='SOC', inplace=True, ignore_index=True) - if return_all: - return params, stats + if return_stats: + return params, amp.Dataset(stats) return params diff --git a/src/ampworks/ici/_extract_params.py b/src/ampworks/ici/_extract_params.py index 69220c0..a58e388 100644 --- a/src/ampworks/ici/_extract_params.py +++ b/src/ampworks/ici/_extract_params.py @@ -12,7 +12,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, - tmax: float = 10, return_all: bool = False) -> pd.DataFrame: + tmax: float = 10, return_stats: bool = False) -> Dataset: """ Extracts parameters from ICI data. @@ -24,17 +24,19 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, The following protocol was used to test this algorithm: 1. Rest for 5 min, log data every 10 s. - - 2. Charge (or discharge) at C/10 for 5 min; include a voltage limit. Log - every 5 s or every 5 mV. - + 2. Charge at C/10 for 5 min; with a voltage limit. Log every 5 s or 5 mV. 3. Rest for 10 seconds, log data every 0.1 s. - 4. Stop if voltage limit reached in (2), otherwise repeat (2) and (3). The protocol assumes formation cycles have already been completed and that the cell was rested until equilibrium before starting the steps above. - Implementation details are available in [1]_. + Implementation details are available in [1]_. This specific protocol assumes + the cell starts at a fully discharged state and only includes charge pulses; + however, you can similarly perform the experiment in the discharge direction + or in both directions. The only change would be to step (2) where you would + discharge at C/20 instead of charge. It is common to perform the ICI tests + in both directions, but you must process the charge and discharge segments + separately by slicing your data and calling this routine twice. Parameters ---------- @@ -50,17 +52,17 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, tmax : float, optional The maximum relative rest time (in seconds) to use when fitting sqrt(t) vs. voltage for time constants. Default is 10. - return_all : bool, optional + return_stats : bool, optional If False (default), only the extracted parameters vs. state of charge are returned. If True, also returns stats with info about each rest. Returns ------- - params : pd.DataFrame + params : Dataset Table of parameters. Columns include 'SOC' (state of charge, -), 'Ds' (diffusivity, m2/s), and 'Eeq' (equilibrium potential, V). - stats : pd.DataFrame - Only returned if `return_all=True`. Provides additional stats about + stats : Dataset + Only returned if `return_stats=True`. Provides additional stats about each rest, including errors from the sqrt(t) vs. voltage regressions. Raises @@ -101,13 +103,15 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, -------- >>> import ampworks as amp >>> data = amp.datasets.load_datasets('ici/ici_discharge') - >>> params, stats = amp.ici.extract_params(data, 1.8e-6, return_all=True) + >>> params, stats = amp.ici.extract_params(data, 1.8e-6, return_stats=True) >>> params.plot('SOC', 'Eeq') >>> params.plot('SOC', 'Ds', logy=True) >>> print(params) >>> print(stats) """ + import ampworks as amp + from ampworks._checks import _check_columns, _check_only_one from ampworks._auxiliary import _infer_state, _calc_soc, _calc_relative_time @@ -121,32 +125,32 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, message="'data' cannot include both charge and discharge segments.", ) - df = data.copy() - df = df.reset_index(drop=True) + ds = data.copy() + ds = ds.reset_index(drop=True) # States based on current direction: charge, discharge, or rests - _infer_state(df) + _infer_state(ds) # Add in state-of-charge column to map each value to an SOC - _calc_soc(df, charging) + _calc_soc(ds, charging) # Count each time a rest/charge or rest/discharge changeover occurs - rest = (df['State'] != 'R') & (df['State'].shift(fill_value='R') == 'R') - df['Rest'] = rest.cumsum() + rest = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Rest'] = rest.cumsum() # Relative time of each rest/charge or rest/discharge step - _calc_relative_time(df, ['Rest', 'State'], col_name='StepTime') + _calc_relative_time(ds, ['Rest', 'State'], col_name='StepTime') # Remove last cycle if not complete, i.e., ended on charge or discharge - if df.iloc[-1]['State'] != 'R': - df = df[df['Rest'] != df['Rest'].max()].reset_index(drop=True) + if ds.iloc[-1]['State'] != 'R': + ds = ds[ds['Rest'] != ds['Rest'].max()].reset_index(drop=True) # Record summary stats for each loop, immediately before the rests - groups = df[df['State'] != 'R'].groupby('Rest', as_index=False) + groups = ds[ds['State'] != 'R'].groupby('Rest', as_index=False) summary = groups.agg(lambda x: x.iloc[-1]) # Store slope and intercepts (V = m*t^0.5 + b) for each rest - groups = df.groupby('Rest') + groups = ds.groupby('Rest') regression = None for idx, g in groups: @@ -186,7 +190,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, stats = pd.merge(summary, regression, on='Rest') stats['dEdt'] = np.gradient(stats['Volts'], stats['Seconds']) - params = pd.DataFrame({ + params = amp.Dataset({ 'SOC': stats['SOC'], 'Ds': 4./9./np.pi * (radius * stats['dEdt']/stats['dUdrt'])**2, 'Eeq': stats['Eeq'], @@ -194,7 +198,7 @@ def extract_params(data: Dataset, radius: float, tmin: float = 1, params.sort_values(by='SOC', inplace=True, ignore_index=True) - if return_all: - return params, stats + if return_stats: + return params, amp.Dataset(stats) return params diff --git a/src/ampworks/labels.py b/src/ampworks/labels.py index 1f71d96..4673022 100644 --- a/src/ampworks/labels.py +++ b/src/ampworks/labels.py @@ -307,6 +307,13 @@ def apply_labels(data: Dataset, labels: LabelSet) -> Dataset: ValueError If `data` does not contain the required 'Cycle' and 'Step' columns. + See Also + -------- + Dataset.interactive_plotly + Interactively inspect the labeled dataset with plotly hover tips. + Dataset.interactive_bokeh + Same using bokeh; better suited for large datasets. + Notes ----- Any cycles or steps that are not labeled will have a label of `'None'` in @@ -352,7 +359,7 @@ def apply_labels(data: Dataset, labels: LabelSet) -> Dataset: # Add an hours column and plot with the labels as hover tips labeled['Hours'] = labeled['Seconds'] / 3600 - labeled.interactive_xy_plot( + labeled.interactive_plotly( x='Hours', y='Volts', tips=['StepLabel', 'CycleLabel'], ) diff --git a/src/ampworks/plotutils/_bokeh.py b/src/ampworks/plotutils/_bokeh.py new file mode 100644 index 0000000..8de9e2b --- /dev/null +++ b/src/ampworks/plotutils/_bokeh.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from tempfile import NamedTemporaryFile + +from IPython.display import display, HTML +from bokeh import io as bk_io, models as bk_m + +if TYPE_CHECKING: # pragma: no cover + from bokeh.plotting import figure as BokehFigure + +__all__ = [ + 'BOKEH_TEMPLATE', + 'BOKEH_CONFIG', + '_apply_bokeh_style', + '_render_bokeh', +] + +BOKEH_TEMPLATE = { + 'margin': (7, 7, 7, 7), # (top, right, bottom, left) + 'border': {'top': 60, 'left': 80, 'right': 80, 'bottom': 80}, + 'minor_tick_len': 3, + 'major_tick_len': 6, + 'font_family': 'Arial', + 'font_size': '10pt', + 'font_style': 'normal', +} + +BOKEH_CONFIG = { + 'active_scroll': 'wheel_zoom', + 'tools': ['pan', 'box_zoom', 'wheel_zoom', 'save', 'reset'], +} + + +def _apply_bokeh_style(fig: BokehFigure) -> None: + """ + Style a bokeh figure. + + Parameters + ---------- + fig : BokehFigure + The bokeh figure to be styled. + + """ + # margin and borders + fig.margin = BOKEH_TEMPLATE['margin'] + + fig.min_border_top = BOKEH_TEMPLATE['border']['top'] + fig.min_border_left = BOKEH_TEMPLATE['border']['left'] + fig.min_border_right = BOKEH_TEMPLATE['border']['right'] + fig.min_border_bottom = BOKEH_TEMPLATE['border']['bottom'] + + # adjust xrange (no left/right padding) + fig.x_range.range_padding = 0 + + # Primary axes (bottom and left) + for ax in fig.axis: + ax.minor_tick_out = 0 + ax.major_tick_out = 0 + + ax.minor_tick_in = BOKEH_TEMPLATE['minor_tick_len'] + ax.major_tick_in = BOKEH_TEMPLATE['major_tick_len'] + + ax.axis_label_text_font_size = BOKEH_TEMPLATE['font_size'] + ax.axis_label_text_font_style = BOKEH_TEMPLATE['font_style'] + + ax.major_label_text_font_size = BOKEH_TEMPLATE['font_size'] + ax.major_label_text_font_style = BOKEH_TEMPLATE['font_style'] + + # Mirrored axes on top and right (ticks only, no labels) + for position in ('above', 'right'): + ax = bk_m.LinearAxis( + minor_tick_out=0, + major_tick_out=0, + minor_tick_in=BOKEH_TEMPLATE['minor_tick_len'], + major_tick_in=BOKEH_TEMPLATE['major_tick_len'], + ) + + ax.major_label_text_font_size = '0pt' + + fig.add_layout(ax, position) + + # Add spanning grid lines for the x=0 and y=0 axes + for direction in ('width', 'height'): + span = bk_m.Span( + location=0, + line_width=1, + line_color='black', + dimension=direction, + ) + + fig.add_layout(span) + + # Hide Bokeh toolbar logo + fig.toolbar.logo = None + + # JS Callback to trigger reset tool on double-click + callback = bk_m.CustomJS(args=dict(fig=fig), code='fig.reset.emit()') + fig.js_on_event('doubletap', callback) + + +def _render_bokeh( + fig: BokehFigure, + figsize: tuple[int, int] | None = None, + save: str | None = None, +) -> None: + """ + Render a Bokeh figure. + + Determine whether to render the figure inline in a notebook or open in the + browser from a user-saved or temporary HTML file. + + Parameters + ---------- + fig : BokehFigure + The bokeh figure to be rendered. + figsize : tuple[int, int] | None, optional + The size of the figure (width, height), by default None. Set either or + both dimensions to None to allow them to stretch. + save : str | None, optional + The file path to save the figure, by default None. + + """ + from ampworks import _in_notebook + + fig.width, fig.height = figsize if figsize is not None else (None, None) + + fig.min_width = max(550, fig.width or 0) + fig.min_height = max(300, fig.height or 0) + + if (fig.width is None) and (fig.height is None): + fig.sizing_mode = 'stretch_both' + elif fig.width is None: + fig.sizing_mode = 'stretch_width' + elif fig.height is None: + fig.sizing_mode = 'stretch_height' + else: + fig.sizing_mode = 'fixed' + + bk_io.reset_output() + + # Save or create temp file to display when not in notebook + if save is not None: + path = Path(save) + if path.suffix.lower() != '.html': + path = path.with_suffix('.html') + + path.parent.mkdir(parents=True, exist_ok=True) + + else: + tmp = NamedTemporaryFile(delete=False, suffix='.html') + path = Path(tmp.name) + tmp.close() + + str_path = str(path) + + # Optionally write to file, then display + in_nb = _in_notebook() + + if save is not None: + bk_io.save(fig, filename=str_path, resources='cdn', title=path.name) + + if not in_nb: + bk_io.output_file(filename=str_path, mode='cdn', title=path.name) + bk_io.show(fig) + elif save is not None: + display(HTML(str_path)) + else: + bk_io.output_notebook(hide_banner=True) + bk_io.show(fig) diff --git a/src/ampworks/plotutils/_plotly.py b/src/ampworks/plotutils/_plotly.py index 56f79e8..a975b18 100644 --- a/src/ampworks/plotutils/_plotly.py +++ b/src/ampworks/plotutils/_plotly.py @@ -7,12 +7,18 @@ import plotly.graph_objects as go if TYPE_CHECKING: # pragma: no cover - from plotly.graph_objs._figure import Figure + from plotly.graph_objs._figure import Figure as PlotlyFigure -__all__ = ['PLOTLY_TEMPLATE', 'PLOTLY_CONFIG', '_render_plotly'] +__all__ = [ + 'PLOTLY_TEMPLATE', + 'PLOTLY_CONFIG', + '_apply_plotly_style', + '_render_plotly', +] PLOTLY_TEMPLATE = go.layout.Template( layout=dict( + hovermode='x', dragmode='pan', plot_bgcolor='white', paper_bgcolor='white', @@ -52,27 +58,37 @@ } +def _apply_plotly_style(fig: PlotlyFigure) -> None: + """ + Style a plotly figure. + + Parameters + ---------- + fig : PlotlyFigure + The plotly figure to be styled. + + """ + fig.update_layout(template=PLOTLY_TEMPLATE) + + def _render_plotly( - fig: Figure, + fig: PlotlyFigure, figsize: tuple[int, int] | None = None, save: str | None = None, ) -> None: """ Render a plotly figure. - Automatically determines whether the code is running in a notebook or from - a script. When run from a notebook, the figure is rendered inline. From a - script, the figure is opened in a local web browser. It is either opened - from the save location, or from a temporary directory, if not saved. + Determine whether to render the figure inline in a notebook or open in the + browser from a user-saved or temporary HTML file. Parameters ---------- - fig : Figure + fig : PlotlyFigure The plotly figure to be rendered. figsize : tuple[int, int] | None, optional - The size of the figure (width, height), by default None. If None, the - default plotly size is used. You may also specify one dimension as None - to make it responsive (i.e., adjust to the page) in that dimension. + The size of the figure (width, height), by default None. Set either or + both dimensions to None to allow them to stretch. save : str | None, optional The file path to save the figure, by default None. diff --git a/tests/test_core/test_dataset.py b/tests/test_core/test_dataset.py index 9379cea..3b5bc01 100644 --- a/tests/test_core/test_dataset.py +++ b/tests/test_core/test_dataset.py @@ -33,6 +33,16 @@ def noisy_data(): }) +@pytest.fixture +def plot_data(): + """A Dataset for interactive plotting tests.""" + return amp.Dataset({ + 'X': [1., 2., 3.], + 'Y': [4., 5., 6.], + 'Tag': ['a', 'b', 'c'], + }) + + # downsample method validation and functionality class TestDownsampleValidation: @@ -392,3 +402,82 @@ def test_inplace_false_leaves_original_unchanged(self): def test_return_type_is_dataset(self, sample_data): result = sample_data.zero_below(column='A', threshold=1.0) assert isinstance(result, amp.Dataset) + + +# interactive_plotly method +class TestInteractivePlotly: + + @pytest.mark.parametrize('kind', ['line', 'scatter', 'both']) + def test_kind(self, plot_data, monkeypatch, kind): + monkeypatch.setattr( + 'ampworks.plotutils._plotly._render_plotly', lambda **kw: None, + ) + plot_data.interactive_plotly('X', 'Y', kind=kind) + + # invalid kind option + with pytest.raises(ValueError): + plot_data.interactive_plotly('X', 'Y', kind='fake') + + def test_with_tips(self, plot_data, monkeypatch): + monkeypatch.setattr( + 'ampworks.plotutils._plotly._render_plotly', lambda **kw: None, + ) + plot_data.interactive_plotly('X', 'Y', tips=['Tag']) + + # invalid tips column + with pytest.raises(ValueError): + plot_data.interactive_plotly('X', 'Y', tips=['Fake']) + + def test_save_forwarded(self, plot_data, monkeypatch): + captured = {} + monkeypatch.setattr( + 'ampworks.plotutils._plotly._render_plotly', + lambda fig, figsize, save: captured.update(save=save), + ) + plot_data.interactive_plotly('X', 'Y', save='out.html') + assert captured['save'] == 'out.html' + + +# interactive_bokeh method +class TestInteractiveBokeh: + + @pytest.mark.parametrize('kind', ['line', 'scatter', 'both']) + def test_kind(self, plot_data, monkeypatch, kind): + monkeypatch.setattr( + 'ampworks.plotutils._bokeh._render_bokeh', lambda **kw: None, + ) + plot_data.interactive_bokeh('X', 'Y', kind=kind) + + # invalid kind option + with pytest.raises(ValueError): + plot_data.interactive_bokeh('X', 'Y', kind='fake') + + def test_with_tips(self, plot_data, monkeypatch): + monkeypatch.setattr( + 'ampworks.plotutils._bokeh._render_bokeh', lambda **kw: None, + ) + plot_data.interactive_bokeh('X', 'Y', tips=['Tag']) + + # invalid tips column + with pytest.raises(KeyError): + plot_data.interactive_bokeh('X', 'Y', tips=['Fake']) + + def test_save_forwarded(self, plot_data, monkeypatch): + captured = {} + monkeypatch.setattr( + 'ampworks.plotutils._bokeh._render_bokeh', + lambda fig, figsize, save: captured.update(save=save), + ) + plot_data.interactive_bokeh('X', 'Y', save='out.html') + assert captured['save'] == 'out.html' + + +# interactive_xy_plot deprecation warning +class TestInteractiveXYPlotDeprecated: + + def test_emits_deprecation_warning(self, plot_data, monkeypatch): + monkeypatch.setattr( + 'ampworks.plotutils._plotly._render_plotly', lambda **kw: None, + ) + with pytest.warns(DeprecationWarning): + plot_data.interactive_xy_plot('X', 'Y') diff --git a/tests/test_gitt.py b/tests/test_gitt.py index 31dafc8..374f3b9 100644 --- a/tests/test_gitt.py +++ b/tests/test_gitt.py @@ -14,24 +14,24 @@ def datasets(): def test_extract_params_missing_columns(): - data = amp.Dataset({'Seconds': [], 'Volts': []}) # missing 'Amps' + ds = amp.Dataset({'Seconds': [], 'Volts': []}) # missing 'Amps' with pytest.raises(ValueError): - _ = amp.gitt.extract_params(data, 1.8e-6) + _ = amp.gitt.extract_params(ds, 1.8e-6) def test_extract_params_charge_discharge(datasets): - data = datasets['discharge'].copy() + ds = datasets['discharge'].copy() - data.loc[0, 'Amps'] = +1 # inject opposite sign + ds.loc[0, 'Amps'] = +1 # inject opposite sign with pytest.raises(ValueError): - _ = amp.gitt.extract_params(data, 1.8e-6) + _ = amp.gitt.extract_params(ds, 1.8e-6) def test_extract_params_basic(datasets): - data = datasets['discharge'].copy() + ds = datasets['discharge'].copy() - # test with discharge data, with return_all=True - params, stats = amp.gitt.extract_params(data, 1.8e-6, return_all=True) + # test with discharge data, with return_stats=True + params, stats = amp.gitt.extract_params(ds, 1.8e-6, return_stats=True) assert isinstance(params, pd.DataFrame) assert {'SOC', 'Ds', 'Eeq'}.issubset(params.columns) @@ -48,10 +48,10 @@ def test_extract_params_basic(datasets): assert np.all((params['Ds'][2:] < 4e-15) & (params['Ds'][2:] > 1e-16)) assert np.all((params['Eeq'] >= 3.0) & (params['Eeq'] <= 4.1)) - # test with charge data - overwrite "data" fixture - data = datasets['charge'].copy() + # test with charge data - overwrite "ds" fixture + ds = datasets['charge'].copy() - params = amp.gitt.extract_params(data, 1.8e-6) + params = amp.gitt.extract_params(ds, 1.8e-6) assert isinstance(params, pd.DataFrame) assert {'SOC', 'Ds', 'Eeq'}.issubset(params.columns) @@ -67,18 +67,17 @@ def test_extract_params_basic(datasets): def test_extract_params_truncate_last_step(datasets): - data = datasets['discharge'].copy() + from ampworks._auxiliary import _infer_state - data['State'] = 'R' - data.loc[data['Amps'] > 0, 'State'] = 'C' - data.loc[data['Amps'] < 0, 'State'] = 'D' + ds = datasets['discharge'].copy() - pulse = (data['State'] != 'R') & ( - data['State'].shift(fill_value='R') == 'R') - data['Pulse'] = pulse.cumsum() + _infer_state(ds) + + pulse = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Pulse'] = pulse.cumsum() # get first 5 discharge/rest steps, then remove last rest - subset = data[data['Pulse'] <= 5] + subset = ds[ds['Pulse'] <= 5] last_rest = subset[(subset['Pulse'] == 5) & (subset['State'] == 'R')] subset = subset.drop(last_rest.index) @@ -89,21 +88,19 @@ def test_extract_params_truncate_last_step(datasets): def test_extract_params_partial_rest(datasets): - data = datasets['discharge'].copy() + from ampworks._auxiliary import _infer_state + + ds = datasets['discharge'].copy() # if tmin and tmax are set such that the number of points is less than two # then the linear regression cannot be performed and NaN is returned + _infer_state(ds) - data['State'] = 'R' - data.loc[data['Amps'] > 0, 'State'] = 'C' - data.loc[data['Amps'] < 0, 'State'] = 'D' - - pulse = (data['State'] != 'R') & ( - data['State'].shift(fill_value='R') == 'R') - data['Pulse'] = pulse.cumsum() + pulse = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Pulse'] = pulse.cumsum() # get first 5 pulse/rest steps, then make last pulse points all < tmin=1 - subset = data[data['Pulse'] <= 5] + subset = ds[ds['Pulse'] <= 5] last = subset[(subset['Pulse'] == 5) & (subset['State'] != 'R')] tmin_limit = last[last['Seconds'] - last['Seconds'].iloc[0] > 1] diff --git a/tests/test_ici.py b/tests/test_ici.py index 732992a..a949d64 100644 --- a/tests/test_ici.py +++ b/tests/test_ici.py @@ -14,24 +14,24 @@ def datasets(): def test_extract_params_missing_columns(): - data = amp.Dataset({'Seconds': [], 'Volts': []}) # missing 'Amps' + ds = amp.Dataset({'Seconds': [], 'Volts': []}) # missing 'Amps' with pytest.raises(ValueError): - _ = amp.ici.extract_params(data, 1.8e-6) + _ = amp.ici.extract_params(ds, 1.8e-6) def test_extract_params_charge_discharge(datasets): - data = datasets['discharge'].copy() + ds = datasets['discharge'].copy() - data.loc[0, 'Amps'] = +1 # inject opposite sign + ds.loc[0, 'Amps'] = +1 # inject opposite sign with pytest.raises(ValueError): - _ = amp.ici.extract_params(data, 1.8e-6) + _ = amp.ici.extract_params(ds, 1.8e-6) def test_extract_params_basic(datasets): - data = datasets['discharge'].copy() + ds = datasets['discharge'].copy() - # test with discharge data, with return_all=True - params, stats = amp.ici.extract_params(data, 1.8e-6, return_all=True) + # test with discharge data, with return_stats=True + params, stats = amp.ici.extract_params(ds, 1.8e-6, return_stats=True) assert isinstance(params, pd.DataFrame) assert {'SOC', 'Ds', 'Eeq'}.issubset(params.columns) @@ -47,10 +47,10 @@ def test_extract_params_basic(datasets): assert np.all((params['Ds'] < 4e-15) & (params['Ds'] > 1e-16)) assert np.all((params['Eeq'] >= 3.0) & (params['Eeq'] <= 4.1)) - # test with charge data - overwrite "data" fixture - data = datasets['charge'].copy() + # test with charge data - overwrite "ds" fixture + ds = datasets['charge'].copy() - params = amp.ici.extract_params(data, 1.8e-6) + params = amp.ici.extract_params(ds, 1.8e-6) assert isinstance(params, pd.DataFrame) assert {'SOC', 'Ds', 'Eeq'}.issubset(params.columns) @@ -65,17 +65,17 @@ def test_extract_params_basic(datasets): def test_extract_params_truncate_last_step(datasets): - data = datasets['discharge'].copy() + from ampworks._auxiliary import _infer_state - data['State'] = 'R' - data.loc[data['Amps'] > 0, 'State'] = 'C' - data.loc[data['Amps'] < 0, 'State'] = 'D' + ds = datasets['discharge'].copy() - rest = (data['State'] != 'R') & (data['State'].shift(fill_value='R') == 'R') - data['Rest'] = rest.cumsum() + _infer_state(ds) + + rest = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Rest'] = rest.cumsum() # get first 5 discharge/rest steps, then remove last rest - subset = data[data['Rest'] <= 5] + subset = ds[ds['Rest'] <= 5] last_rest = subset[(subset['Rest'] == 5) & (subset['State'] == 'R')] subset = subset.drop(last_rest.index) @@ -86,20 +86,19 @@ def test_extract_params_truncate_last_step(datasets): def test_extract_params_rest_not_in_twindow(datasets): - data = datasets['discharge'].copy() + from ampworks._auxiliary import _infer_state + + ds = datasets['discharge'].copy() # if tmin and tmax are set such that the number of points is less than two # then the linear regression cannot be performed and NaN is returned + _infer_state(ds) - data['State'] = 'R' - data.loc[data['Amps'] > 0, 'State'] = 'C' - data.loc[data['Amps'] < 0, 'State'] = 'D' - - rest = (data['State'] != 'R') & (data['State'].shift(fill_value='R') == 'R') - data['Rest'] = rest.cumsum() + rest = (ds['State'] != 'R') & (ds['State'].shift(fill_value='R') == 'R') + ds['Rest'] = rest.cumsum() # get first 5 pulse/rest steps, then make last rest points all < tmin=1 - subset = data[data['Rest'] <= 5] + subset = ds[ds['Rest'] <= 5] last = subset[(subset['Rest'] == 5) & (subset['State'] == 'R')] tmin_limit = last[last['Seconds'] - last['Seconds'].iloc[0] > 1] diff --git a/tests/test_plotutils.py b/tests/test_plotutils.py index fb986cb..745823e 100644 --- a/tests/test_plotutils.py +++ b/tests/test_plotutils.py @@ -1,173 +1,263 @@ import pytest import numpy as np +import plotly.graph_objects as go + +from bokeh.plotting import figure from matplotlib import pyplot as plt from matplotlib.ticker import AutoMinorLocator from ampworks import plotutils as aplt +from ampworks.plotutils._bokeh import _render_bokeh +from ampworks.plotutils._plotly import _render_plotly # tests for _colors submodule -def test_cmap_init(): +class TestColorMap: - cm = aplt.ColorMap('viridis', (0, 1)) + def test_init(self): + cm = aplt.ColorMap('viridis', (0, 1)) - assert cm._vmin == 0 - assert cm._vmax == 1 - assert hasattr(cm, '_sm') + assert cm._vmin == 0 + assert cm._vmax == 1 + assert hasattr(cm, '_sm') - with pytest.raises(ValueError): - aplt.ColorMap('viridis', (0,)) # norm must be length 2 + with pytest.raises(ValueError): + aplt.ColorMap('viridis', (0,)) # norm must be length 2 - with pytest.raises(ValueError): - aplt.ColorMap('viridis', (1, 0)) # vmin must be < vmax + with pytest.raises(ValueError): + aplt.ColorMap('viridis', (1, 0)) # vmin must be < vmax + def test_get_color(self): + cm = aplt.ColorMap('viridis', (0, 1)) + color = cm.get_color(0.5) -def test_cmap_get_color(): + assert isinstance(color, tuple) + assert len(color) == 4 # RGBA - cm = aplt.ColorMap('viridis', (0, 1)) + with pytest.raises(ValueError): + cm.get_color(1.5) - color = cm.get_color(0.5) + def test_colors_from_size(self): + size = 5 + colors = aplt.colors_from_size(size, 'viridis') - assert isinstance(color, tuple) - assert len(color) == 4 # RGBA + assert isinstance(colors, list) + assert len(colors) == size + assert all(len(c) == 4 for c in colors) - cm = aplt.ColorMap('viridis', (0, 1)) - with pytest.raises(ValueError): - cm.get_color(1.5) + def test_colors_from_data(self): + data = np.array([[0, 0.5], [0.8, 1]]) + colors = aplt.colors_from_data(data, 'viridis') + assert isinstance(colors, np.ndarray) + assert colors.shape == data.shape -def test_cmap_colors_from_size(): + for row in colors: + for c in row: + assert len(c) == 4 - size = 5 - colors = aplt.colors_from_size(size, 'viridis') - assert isinstance(colors, list) - assert len(colors) == size - assert all(len(c) == 4 for c in colors) +# tests for _text submodule +class TestAddText: + def test_add_text(self): + fig, ax = plt.subplots() -def test_cmap_colors_from_data(): + aplt.add_text(ax, 0.1, 0.1, 'First') + aplt.add_text(ax, 0.9, 0.9, 'Second') - data = np.array([[0, 0.5], [0.8, 1]]) - colors = aplt.colors_from_data(data, 'viridis') + texts = [t.get_text() for t in ax.texts] - assert isinstance(colors, np.ndarray) - assert colors.shape == data.shape + assert 'First' in texts + assert 'Second' in texts + assert len(ax.texts) == 2 - for row in colors: - for c in row: - assert len(c) == 4 + plt.close(fig) + def test_add_text_alignment(self): + fig, ax = plt.subplots() -# tests for _text submodule -def test_add_text(): + aplt.add_text(ax, 0.3, 0.7, 'Aligned', ha='left', va='top') - # basic - fig, ax = plt.subplots() + text = ax.texts[0] - aplt.add_text(ax, 0.1, 0.1, 'First') - aplt.add_text(ax, 0.9, 0.9, 'Second') + assert text.get_ha() == 'left' + assert text.get_va() == 'top' - texts = [t.get_text() for t in ax.texts] + plt.close(fig) - assert 'First' in texts - assert 'Second' in texts - assert len(ax.texts) == 2 - plt.close(fig) +# tests for _ticks submodule +class TestTicks: - # alignment - fig, ax = plt.subplots() + def test_minor_ticks_defaults(self): + fig, ax = plt.subplots() + aplt.minor_ticks(ax) - aplt.add_text(ax, 0.3, 0.7, 'Aligned', ha='left', va='top') + assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) + assert isinstance(ax.yaxis.get_minor_locator(), AutoMinorLocator) - text = ax.texts[0] + plt.close(fig) - assert text.get_ha() == 'left' - assert text.get_va() == 'top' + def test_minor_ticks_custom(self): + fig, ax = plt.subplots() + aplt.minor_ticks(ax, xdiv=4, ydiv=3) - plt.close(fig) + xloc = ax.xaxis.get_minor_locator() + yloc = ax.yaxis.get_minor_locator() + assert isinstance(xloc, AutoMinorLocator) and xloc.ndivs == 4 + assert isinstance(yloc, AutoMinorLocator) and yloc.ndivs == 3 -# tests for _ticks submodule -def test_minor_ticks(): + plt.close(fig) + + def test_tick_direction_defaults(self): + fig, ax = plt.subplots() + aplt.tick_direction(ax) + + xparams = ax.xaxis.get_tick_params() + yparams = ax.yaxis.get_tick_params() + + if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 + xparams['top'] = xparams.get('right') + + assert xparams['direction'] == 'in' and xparams['top'] + assert yparams['direction'] == 'in' and yparams['right'] + + plt.close(fig) + + def test_tick_direction_custom(self): + fig, ax = plt.subplots() + aplt.tick_direction( + ax, xdir='out', ydir='inout', top=False, right=False, + ) + + xparams = ax.xaxis.get_tick_params() + yparams = ax.yaxis.get_tick_params() + + if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 + xparams['top'] = xparams.get('right') + + assert xparams['direction'] == 'out' and not xparams['top'] + assert yparams['direction'] == 'inout' and not yparams['right'] + + plt.close(fig) + + def test_format_ticks(self): + fig, ax = plt.subplots() + aplt.format_ticks( + ax, + xdiv=4, ydiv=3, + xdir='out', ydir='inout', + top=False, right=False, + ) + + xloc = ax.xaxis.get_minor_locator() + yloc = ax.yaxis.get_minor_locator() + + xparams = ax.xaxis.get_tick_params() + yparams = ax.yaxis.get_tick_params() + + assert isinstance(xloc, AutoMinorLocator) and xloc.ndivs == 4 + assert isinstance(yloc, AutoMinorLocator) and yloc.ndivs == 3 + + if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 + xparams['top'] = xparams.get('right') + + assert xparams['direction'] == 'out' and not xparams['top'] + assert yparams['direction'] == 'inout' and not yparams['right'] + + plt.close(fig) - # defaults - fig, ax = plt.subplots() - aplt.minor_ticks(ax) - assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) - assert isinstance(ax.yaxis.get_minor_locator(), AutoMinorLocator) +# tests for plotutils._plotly._render_plotly +class TestRenderPlotly: - plt.close(fig) + @pytest.fixture(autouse=True) + def _not_in_notebook(self, monkeypatch): + monkeypatch.setattr('ampworks._in_notebook', lambda: False) - # custom - fig, ax = plt.subplots() - aplt.minor_ticks(ax, xdiv=4, ydiv=3) + def test_save_writes_file_and_opens(self, tmp_path, monkeypatch): + opened = [] + monkeypatch.setattr( + 'webbrowser.open', lambda url, **kw: opened.append(url), + ) - xloc = ax.xaxis.get_minor_locator() - yloc = ax.yaxis.get_minor_locator() + save_path = tmp_path / 'chart.html' + _render_plotly(go.Figure(), save=str(save_path)) - assert isinstance(xloc, AutoMinorLocator) and xloc.ndivs == 4 - assert isinstance(yloc, AutoMinorLocator) and yloc.ndivs == 3 + assert len(opened) == 1 + assert save_path.exists() - plt.close(fig) + def test_save_adds_html_extension(self, tmp_path, monkeypatch): + monkeypatch.setattr('webbrowser.open', lambda *a, **kw: None) + _render_plotly(go.Figure(), save=str(tmp_path / 'chart')) -def test_tick_direction(): + assert (tmp_path / 'chart.html').exists() - # defaults - fig, ax = plt.subplots() - aplt.tick_direction(ax) + def test_no_save_creates_temp_html(self, tmp_path, monkeypatch): + opened = [] + monkeypatch.setattr( + 'webbrowser.open', lambda url, **kw: opened.append(url), + ) - xparams = ax.xaxis.get_tick_params() - yparams = ax.yaxis.get_tick_params() + _render_plotly(go.Figure()) - if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 - xparams['top'] = xparams.get('right') + assert len(opened) == 1 + assert opened[0].endswith('.html') + assert opened[0].startswith('file://') - assert xparams['direction'] == 'in' and xparams['top'] - assert yparams['direction'] == 'in' and yparams['right'] - plt.close(fig) +# tests for plotutils._bokeh._render_bokeh +class TestRenderBokeh: - # custom - fig, ax = plt.subplots() - aplt.tick_direction(ax, xdir='out', ydir='inout', top=False, right=False) + @pytest.fixture(autouse=True) + def _not_in_notebook(self, monkeypatch): + monkeypatch.setattr('ampworks._in_notebook', lambda: False) - xparams = ax.xaxis.get_tick_params() - yparams = ax.yaxis.get_tick_params() + def test_save_writes_file_and_opens(self, tmp_path, monkeypatch): + shown = [] + monkeypatch.setattr( + 'bokeh.io.show', lambda *a, **kw: shown.append(True), + ) - if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 - xparams['top'] = xparams.get('right') + out = [] + monkeypatch.setattr( + 'bokeh.io.output_file', lambda *a, **kw: out.append(kw['filename']), + ) - assert xparams['direction'] == 'out' and not xparams['top'] - assert yparams['direction'] == 'inout' and not yparams['right'] + save_path = tmp_path / 'chart.html' + _render_bokeh(figure(), save=str(save_path)) - plt.close(fig) + assert len(shown) == 1 + assert save_path.exists() + assert len(out) == 1 + assert out[0] == str(save_path) -def test_format_ticks(): + def test_save_adds_html_extension(self, tmp_path, monkeypatch): + monkeypatch.setattr('bokeh.io.show', lambda *a, **kw: None) - fig, ax = plt.subplots() - aplt.format_ticks( - ax, xdiv=4, ydiv=3, xdir='out', ydir='inout', top=False, right=False, - ) + _render_bokeh(figure(), save=str(tmp_path / 'chart')) - xloc = ax.xaxis.get_minor_locator() - yloc = ax.yaxis.get_minor_locator() + assert (tmp_path / 'chart.html').exists() - xparams = ax.xaxis.get_tick_params() - yparams = ax.yaxis.get_tick_params() + def test_no_save_creates_temp_html(self, monkeypatch): + shown = [] + monkeypatch.setattr( + 'bokeh.io.show', lambda *a, **kw: shown.append(True), + ) - assert isinstance(xloc, AutoMinorLocator) and xloc.ndivs == 4 - assert isinstance(yloc, AutoMinorLocator) and yloc.ndivs == 3 + out = [] + monkeypatch.setattr( + 'bokeh.io.output_file', lambda *a, **kw: out.append(kw['filename']), + ) - if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 - xparams['top'] = xparams.get('right') + _render_bokeh(figure()) - assert xparams['direction'] == 'out' and not xparams['top'] - assert yparams['direction'] == 'inout' and not yparams['right'] + assert len(shown) == 1 - plt.close(fig) + assert len(out) == 1 + assert out[0].endswith('.html')