From af10f6f3b2ff1cdaa4270624553bb9fe70bceef2 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Thu, 14 May 2026 13:33:02 -0600 Subject: [PATCH 01/12] Change return_all to return_stats in ICI/GITT extract_params --- src/ampworks/_auxiliary.py | 6 +-- src/ampworks/gitt/_extract_params.py | 56 +++++++++++++++------------- src/ampworks/ici/_extract_params.py | 56 +++++++++++++++------------- tests/test_gitt.py | 53 +++++++++++++------------- tests/test_ici.py | 51 +++++++++++++------------ 5 files changed, 113 insertions(+), 109 deletions(-) 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/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/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] From a1ffadd46afa8c0023ad99a71c559b5ac6bb515c Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 09:15:56 -0600 Subject: [PATCH 02/12] Change interactive plots, one for plotly, another for bokeh --- pyproject.toml | 1 + src/ampworks/_core/_dataset.py | 171 +++++++++++++++++++++++++++--- src/ampworks/plotutils/_bokeh.py | 156 +++++++++++++++++++++++++++ src/ampworks/plotutils/_plotly.py | 26 ++++- 4 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 src/ampworks/plotutils/_bokeh.py diff --git a/pyproject.toml b/pyproject.toml index 6f2ce37..f8a029a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "tqdm", "xlrd", "numpy", + "bokeh", "pandas", "polars", "plotly", diff --git a/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index dd46e22..e2117a9 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -4,6 +4,9 @@ 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.""" @@ -233,12 +236,107 @@ 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, int] = (800, 450), + markers: bool = False, + 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. + + 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, int], optional + Figure size (width, height) in pixels, by default (800, 450). Set + one or both dimensions to None to make the plot responsive (i.e., + the width and/or height will adjust to the page). + markers : bool, optional + If True, show markers at each data point. Default is False. + save : str, optional + File path to save the plot as an HTML file, by default None. + + See Also + -------- + interactive_bokeh : Similar interactive plots with a bokeh backend. It + is typically more performant than plotly for larger datasets. + + 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. + You can also choose to save to a non-temporary directory, in which case + the file is saved in the specified location and opened from there. + + The responsive height size option is limited in notebook environments. + Since output cells do not have adjustable heights, the height will be + set to a default minimum value. Despite this limitation, the responsive + height is fully functional for saved HTML files. Additionally, the width + is fully responsive in both notebooks and saved HTML files. + + 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 numbers. Even though only one hover tip is requested, it must + be passed in a list. Add more names for additional hover tips. + + 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'. + + .. 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']) + + """ + from ampworks.plotutils._plotly import ( + _apply_plotly_style, _render_plotly, + ) + + hover_data = {} if tips is None else {col: True for col in tips} + + fig = px.line(self, x=x, y=y, markers=markers, hover_data=hover_data) + + _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, int] = (800, 450), + markers: bool = False, + save: str = None, ) -> None: """ - Create an interactive XY plot using Plotly. Allows hovertips, zooming, + Create an interactive XY plot using bokeh. 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. @@ -254,23 +352,40 @@ def interactive_xy_plot( 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). + Figure size (width, height) in pixels, by default (800, 450). Set + one or both dimensions to None to make the plot responsive (i.e., + the width and/or height will adjust to the page). + markers : bool, optional + If True, show markers at each data point. Default is False. save : str, optional File path to save the plot as an HTML file, by default None. + See Also + -------- + interactive_plotly : Similar interactive plots with a plotly backend. It + is typically less performant than bokeh for larger datasets, but is + a legacy function and is compatible with `dash` applications. + 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. + You can also choose to save to a non-temporary directory, in which case + the file is saved in the specified location and opened from there. + + The responsive height size option is limited in notebook environments. + Since output cells do not have adjustable heights, the height will be + set to a default minimum value. Despite this limitation, the responsive + height is fully functional for saved HTML files. Additionally, the width + is fully responsive in both notebooks and saved HTML files. 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 step numbers. Even though only one hover tip is requested, it must + be passed in a list. Add more names for additional hover tips. 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, @@ -282,25 +397,49 @@ def interactive_xy_plot( 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']) """ - from ampworks.plotutils._plotly import PLOTLY_TEMPLATE, _render_plotly + from ampworks.plotutils._bokeh import _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}, + 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], + active_scroll='wheel_zoom', + tools=['pan', 'box_zoom', 'wheel_zoom', 'save', 'reset'], ) - fig.update_layout(template=PLOTLY_TEMPLATE) - _render_plotly(fig=fig, figsize=figsize, save=save) + line = fig.line(x=x, y=y, source=source, color=color, line_width=2) + + if markers: + fig.scatter(x=x, y=y, source=source, color=color, size=6) + + # 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 zero_below( self, diff --git a/src/ampworks/plotutils/_bokeh.py b/src/ampworks/plotutils/_bokeh.py new file mode 100644 index 0000000..c81b5a4 --- /dev/null +++ b/src/ampworks/plotutils/_bokeh.py @@ -0,0 +1,156 @@ +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.models import LinearAxis, Span, CustomJS +from bokeh.io import ( + reset_output, output_notebook, output_file, show, save as bk_save, +) + +if TYPE_CHECKING: # pragma: no cover + from bokeh.plotting import figure as BokehFigure + +__all__ = ['_apply_bokeh_style', '_render_bokeh'] + + +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 = (7, 7, 7, 7) # (left, right, top, bottom) + + fig.min_border_top = 60 + fig.min_border_left = 80 + fig.min_border_right = 80 + fig.min_border_bottom = 80 + + # adjust xrange (no left/right padding) + fig.x_range.range_padding = 0 + + # Primary axes (bottom and left) + for ax in fig.axis: + ax.major_tick_in = 6 + ax.major_tick_out = 0 + ax.minor_tick_in = 3 + ax.minor_tick_out = 0 + + ax.axis_label_text_font_size = '10pt' + ax.axis_label_text_font_style = 'normal' + + ax.major_label_text_font_size = '9pt' + ax.major_label_text_font_style = 'normal' + + # Mirrored axes on top and right (ticks only, no labels) + for position in ('above', 'right'): + ax = LinearAxis( + major_tick_in=6, + major_tick_out=0, + minor_tick_in=3, + minor_tick_out=0, + ) + + 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 = 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 = 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. + + 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. + + 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. If None, the + default bokeh size is used. You may also specify one dimension as None + to make it responsive (i.e., adjust to the page) in that dimension. + 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' + + 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_save(fig, filename=str_path, resources='cdn', title=path.name) + + if not in_nb: + output_file(filename=str_path, mode='cdn', title=path.name) + show(fig) + elif save is not None: + display(HTML(str_path)) + else: + output_notebook(hide_banner=True) + show(fig) diff --git a/src/ampworks/plotutils/_plotly.py b/src/ampworks/plotutils/_plotly.py index 56f79e8..e12e21e 100644 --- a/src/ampworks/plotutils/_plotly.py +++ b/src/ampworks/plotutils/_plotly.py @@ -7,9 +7,14 @@ 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( @@ -52,8 +57,21 @@ } +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: @@ -67,7 +85,7 @@ def _render_plotly( 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 From 98698ab3a7603f4dc9a87211bb69ca1bbd7e7b09 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 09:27:56 -0600 Subject: [PATCH 03/12] Update pyproject keywords --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8a029a..e985566 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"] From 49f86e5c053b730f84b1b26842df9ec6723d9d0d Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 10:57:57 -0600 Subject: [PATCH 04/12] Cleanup docstrings, add 'kind' option to interactive plots instead of 'markers' --- src/ampworks/_core/_dataset.py | 192 ++++++++++++++---------------- src/ampworks/plotutils/_bokeh.py | 89 ++++++++------ src/ampworks/plotutils/_plotly.py | 11 +- 3 files changed, 146 insertions(+), 146 deletions(-) diff --git a/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index e2117a9..b3df4c5 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal + import numpy as np import pandas as pd import plotly.express as px @@ -26,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 ---------- @@ -40,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 @@ -61,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 @@ -243,16 +243,12 @@ def interactive_plotly( *, tips: list[str] | None = None, figsize: tuple[int, int] = (800, 450), - markers: bool = False, + 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 ---------- @@ -264,43 +260,32 @@ def interactive_plotly( 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). Set - one or both dimensions to None to make the plot responsive (i.e., - the width and/or height will adjust to the page). - markers : bool, optional - If True, show markers at each data point. Default is False. + 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 : Similar interactive plots with a bokeh backend. It - is typically more performant than plotly for larger datasets. + interactive_bokeh + Interactive plots using bokeh. Typically has higher performance for + large (100k - 1M)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. - You can also choose to save to a non-temporary directory, in which case - the file is saved in the specified location and opened from there. - - The responsive height size option is limited in notebook environments. - Since output cells do not have adjustable heights, the height will be - set to a default minimum value. Despite this limitation, the responsive - height is fully functional for saved HTML files. Additionally, the width - is fully responsive in both notebooks and saved HTML files. + 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 numbers. Even though only one hover tip is requested, it must - be passed in a list. Add more names for additional hover tips. - - 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'. + 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 @@ -311,16 +296,27 @@ def interactive_plotly( # Add new column to plot time in hours instead of seconds data['Hours'] = data['Seconds'] / 3600 - data.interactive_plotly('Hours', 'Volts', tips=['Step']) + data.interactive_plotly('Hours', 'Volts', tips=['Step', 'Amps']) """ from ampworks.plotutils._plotly import ( _apply_plotly_style, _render_plotly, ) - hover_data = {} if tips is None else {col: True for col in tips} + hover = {} if tips is None else {col: True for col in tips} - fig = px.line(self, x=x, y=y, markers=markers, hover_data=hover_data) + 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) @@ -332,16 +328,12 @@ def interactive_bokeh( *, tips: list[str] | None = None, figsize: tuple[int, int] = (800, 450), - markers: bool = False, + kind: Literal['line', 'scatter', 'both'] = 'line', save: str = None, ) -> None: """ - Create an interactive XY plot using bokeh. 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 bokeh figure with hover tips. Optionally save as + a standalone HTML file, viewable without installing Python/ampworks. Parameters ---------- @@ -353,44 +345,32 @@ def interactive_bokeh( 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). Set - one or both dimensions to None to make the plot responsive (i.e., - the width and/or height will adjust to the page). - markers : bool, optional - If True, show markers at each data point. Default is False. + 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 : Similar interactive plots with a plotly backend. It - is typically less performant than bokeh for larger datasets, but is - a legacy function and is compatible with `dash` applications. + interactive_plotly + Interactive plots using plotly. Typically has lower performance for + large (100k - 1M) datasets, but is compatible with `dash` apps. 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. - You can also choose to save to a non-temporary directory, in which case - the file is saved in the specified location and opened from there. - - The responsive height size option is limited in notebook environments. - Since output cells do not have adjustable heights, the height will be - set to a default minimum value. Despite this limitation, the responsive - height is fully functional for saved HTML files. Additionally, the width - is fully responsive in both notebooks and saved HTML files. + 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 numbers. Even though only one hover tip is requested, it must - be passed in a list. Add more names for additional hover tips. - - 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'. + 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 @@ -401,14 +381,18 @@ def interactive_bokeh( # Add new column to plot time in hours instead of seconds data['Hours'] = data['Seconds'] / 3600 - data.interactive_bokeh('Hours', 'Volts', tips=['Step']) + data.interactive_bokeh('Hours', 'Volts', tips=['Step', 'Amps']) """ - from ampworks.plotutils._bokeh import _apply_bokeh_style, _render_bokeh + from ampworks.plotutils._bokeh import ( + BOKEH_CONFIG, _apply_bokeh_style, _render_bokeh, + ) if tips is None: tips = [] + kind = kind.lower() + color = '#636EFA' # adopt color from plotly's default cols = [x, y] + tips @@ -424,14 +408,24 @@ def interactive_bokeh( y_axis_label=y, width=figsize[0], height=figsize[1], - active_scroll='wheel_zoom', - tools=['pan', 'box_zoom', 'wheel_zoom', 'save', 'reset'], + **BOKEH_CONFIG, ) + 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) - if markers: - fig.scatter(x=x, y=y, source=source, color=color, size=6) + # 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 @@ -455,11 +449,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 ------- @@ -468,15 +461,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 @@ -500,14 +489,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/plotutils/_bokeh.py b/src/ampworks/plotutils/_bokeh.py index c81b5a4..8de9e2b 100644 --- a/src/ampworks/plotutils/_bokeh.py +++ b/src/ampworks/plotutils/_bokeh.py @@ -5,15 +5,32 @@ from tempfile import NamedTemporaryFile from IPython.display import display, HTML -from bokeh.models import LinearAxis, Span, CustomJS -from bokeh.io import ( - reset_output, output_notebook, output_file, show, save as bk_save, -) +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__ = ['_apply_bokeh_style', '_render_bokeh'] +__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: @@ -27,36 +44,37 @@ def _apply_bokeh_style(fig: BokehFigure) -> None: """ # margin and borders - fig.margin = (7, 7, 7, 7) # (left, right, top, bottom) + fig.margin = BOKEH_TEMPLATE['margin'] - fig.min_border_top = 60 - fig.min_border_left = 80 - fig.min_border_right = 80 - fig.min_border_bottom = 80 + 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.major_tick_in = 6 - ax.major_tick_out = 0 - ax.minor_tick_in = 3 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 = '10pt' - ax.axis_label_text_font_style = 'normal' + 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 = '9pt' - ax.major_label_text_font_style = 'normal' + 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 = LinearAxis( - major_tick_in=6, - major_tick_out=0, - minor_tick_in=3, + 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' @@ -65,7 +83,7 @@ def _apply_bokeh_style(fig: BokehFigure) -> None: # Add spanning grid lines for the x=0 and y=0 axes for direction in ('width', 'height'): - span = Span( + span = bk_m.Span( location=0, line_width=1, line_color='black', @@ -78,7 +96,7 @@ def _apply_bokeh_style(fig: BokehFigure) -> None: fig.toolbar.logo = None # JS Callback to trigger reset tool on double-click - callback = CustomJS(args=dict(fig=fig), code='fig.reset.emit()') + callback = bk_m.CustomJS(args=dict(fig=fig), code='fig.reset.emit()') fig.js_on_event('doubletap', callback) @@ -90,19 +108,16 @@ def _render_bokeh( """ Render a Bokeh 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 : BokehFigure - The Bokeh figure to be rendered. + The bokeh figure to be rendered. figsize : tuple[int, int] | None, optional - The size of the figure (width, height), by default None. If None, the - default bokeh 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. @@ -123,7 +138,7 @@ def _render_bokeh( else: fig.sizing_mode = 'fixed' - reset_output() + bk_io.reset_output() # Save or create temp file to display when not in notebook if save is not None: @@ -144,13 +159,13 @@ def _render_bokeh( in_nb = _in_notebook() if save is not None: - bk_save(fig, filename=str_path, resources='cdn', title=path.name) + bk_io.save(fig, filename=str_path, resources='cdn', title=path.name) if not in_nb: - output_file(filename=str_path, mode='cdn', title=path.name) - show(fig) + 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: - output_notebook(hide_banner=True) - show(fig) + 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 e12e21e..5075b20 100644 --- a/src/ampworks/plotutils/_plotly.py +++ b/src/ampworks/plotutils/_plotly.py @@ -78,19 +78,16 @@ def _render_plotly( """ 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 : 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. From 73dab2df81cec6fdf1f65e6449b74983d5bfc79c Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 11:12:35 -0600 Subject: [PATCH 05/12] Fix docs typos and make sure new interative_* plot methods show up --- docs/source/conf.py | 7 ++++--- src/ampworks/_core/_dataset.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) 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/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index b3df4c5..6cf0e9a 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -271,7 +271,7 @@ def interactive_plotly( -------- interactive_bokeh Interactive plots using bokeh. Typically has higher performance for - large (100k - 1M)datasets and better support for notebook exports. + large (100k - 1M) datasets and better support for notebook exports. Notes ----- From e97e4c40f4304268cd3d95b80f84bd9ca8e58e73 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 13:48:17 -0600 Subject: [PATCH 06/12] Deprecate interactive_xy_plot() method --- CHANGELOG.md | 4 ++++ src/ampworks/_core/_dataset.py | 29 +++++++++++++++++++++++++---- src/ampworks/labels.py | 9 ++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1bd28..a409339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,12 @@ ## [Unreleased](https://github.com/NatLabRockies/ampworks) ### New Features +- New `interactive_plotly` and `interactive_bokeh` methods with `kind` line/scatter option ([#XX]) - 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` ([#XX]) + ### Optimizations None. diff --git a/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index 6cf0e9a..7e7c9eb 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -242,7 +242,7 @@ def interactive_plotly( y: str, *, tips: list[str] | None = None, - figsize: tuple[int, int] = (800, 450), + figsize: tuple[int | None, int | None] = (800, 450), kind: Literal['line', 'scatter', 'both'] = 'line', save: str = None, ) -> None: @@ -258,7 +258,7 @@ def interactive_plotly( 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 + 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 @@ -327,7 +327,7 @@ def interactive_bokeh( y: str, *, tips: list[str] | None = None, - figsize: tuple[int, int] = (800, 450), + figsize: tuple[int | None, int | None] = (800, 450), kind: Literal['line', 'scatter', 'both'] = 'line', save: str = None, ) -> None: @@ -343,7 +343,7 @@ def interactive_bokeh( 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 + 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 @@ -435,6 +435,27 @@ def interactive_bokeh( _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. Use :meth:`interactive_plotly` instead.""" + import warnings + warnings.warn( + "interactive_xy_plot() is deprecated and will be removed in a " + "future version. Use interactive_plotly() instead.", + DeprecationWarning, + stacklevel=2, + ) + self.interactive_plotly( + x=x, y=y, tips=tips, figsize=figsize, kind='both', save=save, + ) + def zero_below( self, column: str, 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'], ) From 9018be3462eea9ed9866688a4b60e1119a548468 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 16:37:28 -0600 Subject: [PATCH 07/12] Add tests for interactive plots and renderers --- tests/test_core/test_dataset.py | 89 ++++++++++++++++++++++++++++++ tests/test_plotutils.py | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) 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_plotutils.py b/tests/test_plotutils.py index fb986cb..475b1e6 100644 --- a/tests/test_plotutils.py +++ b/tests/test_plotutils.py @@ -1,10 +1,15 @@ 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 @@ -171,3 +176,95 @@ def test_format_ticks(): assert yparams['direction'] == 'inout' and not yparams['right'] plt.close(fig) + + +# tests for plotutils._plotly._render_plotly +class TestRenderPlotly: + + @pytest.fixture(autouse=True) + def _not_in_notebook(self, monkeypatch): + monkeypatch.setattr('ampworks._in_notebook', lambda: False) + + def test_save_writes_file_and_opens(self, tmp_path, monkeypatch): + opened = [] + monkeypatch.setattr( + 'webbrowser.open', lambda url, **kw: opened.append(url), + ) + + save_path = tmp_path / 'chart.html' + _render_plotly(go.Figure(), save=str(save_path)) + + assert len(opened) == 1 + assert save_path.exists() + + 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')) + + assert (tmp_path / 'chart.html').exists() + + def test_no_save_creates_temp_html(self, tmp_path, monkeypatch): + opened = [] + monkeypatch.setattr( + 'webbrowser.open', lambda url, **kw: opened.append(url), + ) + + _render_plotly(go.Figure()) + + assert len(opened) == 1 + assert opened[0].endswith('.html') + assert opened[0].startswith('file://') + + +# tests for plotutils._bokeh._render_bokeh +class TestRenderBokeh: + + @pytest.fixture(autouse=True) + def _not_in_notebook(self, monkeypatch): + monkeypatch.setattr('ampworks._in_notebook', lambda: False) + + def test_save_writes_file_and_opens(self, tmp_path, monkeypatch): + shown = [] + monkeypatch.setattr( + 'bokeh.io.show', lambda *a, **kw: shown.append(True), + ) + + out = [] + monkeypatch.setattr( + 'bokeh.io.output_file', lambda *a, **kw: out.append(kw['filename']), + ) + + save_path = tmp_path / 'chart.html' + _render_bokeh(figure(), save=str(save_path)) + + assert len(shown) == 1 + assert save_path.exists() + + assert len(out) == 1 + assert out[0] == str(save_path) + + def test_save_adds_html_extension(self, tmp_path, monkeypatch): + monkeypatch.setattr('bokeh.io.show', lambda *a, **kw: None) + + _render_bokeh(figure(), save=str(tmp_path / 'chart')) + + assert (tmp_path / 'chart.html').exists() + + def test_no_save_creates_temp_html(self, monkeypatch): + shown = [] + monkeypatch.setattr( + 'bokeh.io.show', lambda *a, **kw: shown.append(True), + ) + + out = [] + monkeypatch.setattr( + 'bokeh.io.output_file', lambda *a, **kw: out.append(kw['filename']), + ) + + _render_bokeh(figure()) + + assert len(shown) == 1 + + assert len(out) == 1 + assert out[0].endswith('.html') From 0aa1fc8ae42c3f2f713b43854c6eb2228f5e20ed Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 16:49:24 -0600 Subject: [PATCH 08/12] Refactor plotutils tests into class-based organization --- tests/test_plotutils.py | 215 +++++++++++++++++++--------------------- 1 file changed, 104 insertions(+), 111 deletions(-) diff --git a/tests/test_plotutils.py b/tests/test_plotutils.py index 475b1e6..745823e 100644 --- a/tests/test_plotutils.py +++ b/tests/test_plotutils.py @@ -13,169 +13,162 @@ # 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(): - - 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) - - -def test_cmap_colors_from_data(): - - 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 - - for row in colors: - for c in row: - assert len(c) == 4 + for row in colors: + for c in row: + assert len(c) == 4 # tests for _text submodule -def test_add_text(): +class TestAddText: - # basic - fig, ax = plt.subplots() + def test_add_text(self): + fig, ax = plt.subplots() - aplt.add_text(ax, 0.1, 0.1, 'First') - aplt.add_text(ax, 0.9, 0.9, 'Second') + aplt.add_text(ax, 0.1, 0.1, 'First') + aplt.add_text(ax, 0.9, 0.9, 'Second') - texts = [t.get_text() for t in ax.texts] + texts = [t.get_text() for t in ax.texts] - assert 'First' in texts - assert 'Second' in texts - assert len(ax.texts) == 2 + assert 'First' in texts + assert 'Second' in texts + assert len(ax.texts) == 2 - plt.close(fig) + plt.close(fig) - # alignment - fig, ax = plt.subplots() + def test_add_text_alignment(self): + fig, ax = plt.subplots() - aplt.add_text(ax, 0.3, 0.7, 'Aligned', ha='left', va='top') + aplt.add_text(ax, 0.3, 0.7, 'Aligned', ha='left', va='top') - text = ax.texts[0] + text = ax.texts[0] - assert text.get_ha() == 'left' - assert text.get_va() == 'top' + assert text.get_ha() == 'left' + assert text.get_va() == 'top' - plt.close(fig) + plt.close(fig) # tests for _ticks submodule -def test_minor_ticks(): +class TestTicks: - # defaults - fig, ax = plt.subplots() - aplt.minor_ticks(ax) + def test_minor_ticks_defaults(self): + fig, ax = plt.subplots() + aplt.minor_ticks(ax) - assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) - assert isinstance(ax.yaxis.get_minor_locator(), AutoMinorLocator) + assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) + assert isinstance(ax.yaxis.get_minor_locator(), AutoMinorLocator) - plt.close(fig) + plt.close(fig) - # custom - fig, ax = plt.subplots() - aplt.minor_ticks(ax, xdiv=4, ydiv=3) + def test_minor_ticks_custom(self): + fig, ax = plt.subplots() + aplt.minor_ticks(ax, xdiv=4, ydiv=3) - xloc = ax.xaxis.get_minor_locator() - yloc = ax.yaxis.get_minor_locator() + 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 + assert isinstance(xloc, AutoMinorLocator) and xloc.ndivs == 4 + assert isinstance(yloc, AutoMinorLocator) and yloc.ndivs == 3 - plt.close(fig) + plt.close(fig) + def test_tick_direction_defaults(self): + fig, ax = plt.subplots() + aplt.tick_direction(ax) -def test_tick_direction(): + xparams = ax.xaxis.get_tick_params() + yparams = ax.yaxis.get_tick_params() - # defaults - fig, ax = plt.subplots() - aplt.tick_direction(ax) + if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 + xparams['top'] = xparams.get('right') - xparams = ax.xaxis.get_tick_params() - yparams = ax.yaxis.get_tick_params() + assert xparams['direction'] == 'in' and xparams['top'] + assert yparams['direction'] == 'in' and yparams['right'] - if 'top' not in xparams.keys(): # for backwards mpl, v <= 3.9 - xparams['top'] = xparams.get('right') + plt.close(fig) - assert xparams['direction'] == 'in' and xparams['top'] - assert yparams['direction'] == 'in' and yparams['right'] - - plt.close(fig) - - # custom - 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') + def test_tick_direction_custom(self): + fig, ax = plt.subplots() + aplt.tick_direction( + ax, xdir='out', ydir='inout', top=False, right=False, + ) - assert xparams['direction'] == 'out' and not xparams['top'] - assert yparams['direction'] == 'inout' and not yparams['right'] + xparams = ax.xaxis.get_tick_params() + yparams = ax.yaxis.get_tick_params() - plt.close(fig) + 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'] -def test_format_ticks(): + plt.close(fig) - fig, ax = plt.subplots() - aplt.format_ticks( - ax, xdiv=4, ydiv=3, xdir='out', ydir='inout', top=False, right=False, - ) + 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() + xloc = ax.xaxis.get_minor_locator() + yloc = ax.yaxis.get_minor_locator() - xparams = ax.xaxis.get_tick_params() - yparams = ax.yaxis.get_tick_params() + 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 + 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') + 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'] + assert xparams['direction'] == 'out' and not xparams['top'] + assert yparams['direction'] == 'inout' and not yparams['right'] - plt.close(fig) + plt.close(fig) # tests for plotutils._plotly._render_plotly From 66fbf574ecc70f1d709955282092e06ea7ba9475 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Fri, 15 May 2026 16:52:25 -0600 Subject: [PATCH 09/12] Add IPython dependency for _render_bokeh --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e985566..bb7d787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pandas", "polars", "plotly", + "IPython", "pyarrow", "seaborn", "openpyxl", From 76b5be3014c07ff1ece87a20810bbc6350480c33 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Sun, 17 May 2026 21:55:33 -0600 Subject: [PATCH 10/12] Move plotly hovermode to 'x' instead of 'closest' for better performance --- src/ampworks/_core/_dataset.py | 14 +++++++++----- src/ampworks/plotutils/_plotly.py | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ampworks/_core/_dataset.py b/src/ampworks/_core/_dataset.py index 7e7c9eb..98c1484 100644 --- a/src/ampworks/_core/_dataset.py +++ b/src/ampworks/_core/_dataset.py @@ -271,7 +271,7 @@ def interactive_plotly( -------- interactive_bokeh Interactive plots using bokeh. Typically has higher performance for - large (100k - 1M) datasets and better support for notebook exports. + large (>250k) datasets and better support for notebook exports. Notes ----- @@ -356,7 +356,7 @@ def interactive_bokeh( -------- interactive_plotly Interactive plots using plotly. Typically has lower performance for - large (100k - 1M) datasets, but is compatible with `dash` apps. + large (>250k) datasets, but is compatible with `dash` apps. Notes ----- @@ -444,11 +444,15 @@ def interactive_xy_plot( figsize: tuple[int | None, int | None] = (800, 450), save: str = None, ) -> None: - """Deprecated. Use :meth:`interactive_plotly` instead.""" + """ + 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 version. Use interactive_plotly() instead.", + "interactive_xy_plot() is deprecated and will be removed in a" + " future release. Use interactive_plotly() or interactive_bokeh().", DeprecationWarning, stacklevel=2, ) diff --git a/src/ampworks/plotutils/_plotly.py b/src/ampworks/plotutils/_plotly.py index 5075b20..a975b18 100644 --- a/src/ampworks/plotutils/_plotly.py +++ b/src/ampworks/plotutils/_plotly.py @@ -18,6 +18,7 @@ PLOTLY_TEMPLATE = go.layout.Template( layout=dict( + hovermode='x', dragmode='pan', plot_bgcolor='white', paper_bgcolor='white', From 3656747258f750ad025c348e7dddca89d9d52aa7 Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Mon, 18 May 2026 11:23:12 -0600 Subject: [PATCH 11/12] Fix reader to work with files from ANL, ragged column issue --- .gitignore | 3 ++- src/ampworks/_core/_headers.py | 8 ++++---- src/ampworks/_core/_read.py | 6 +++++- 3 files changed, 11 insertions(+), 6 deletions(-) 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/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: From f4918bd79faf8d46b057d47260ec4ae0042142fe Mon Sep 17 00:00:00 2001 From: "Corey R. Randall" Date: Mon, 18 May 2026 11:49:35 -0600 Subject: [PATCH 12/12] Update CHANGELOG for PR#31 --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a409339..1a06d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,17 @@ ## [Unreleased](https://github.com/NatLabRockies/ampworks) ### New Features -- New `interactive_plotly` and `interactive_bokeh` methods with `kind` line/scatter option ([#XX]) +- 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` ([#XX]) +- `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))