Add plot functionality to electrochemistry

This commit is contained in:
rasmusvt 2021-10-13 18:06:56 +02:00
parent 4f255fd9d5
commit 43e6ef27c8
2 changed files with 433 additions and 33 deletions

View file

@ -4,6 +4,22 @@ import matplotlib.pyplot as plt
import os import os
def read_data(path, kind, options=None):
if kind == 'neware':
df = read_neware(path)
cycles = process_neware_data(df, options=options)
elif kind == 'batsmall':
df = read_batsmall(path)
cycles = process_batsmall_data(df=df, options=options)
elif kind == 'biologic':
df = read_biologic(path)
cycles = process_biologic_data(df=df, options=options)
return cycles
def read_batsmall(path): def read_batsmall(path):
''' Reads BATSMALL-data into a DataFrame. ''' Reads BATSMALL-data into a DataFrame.
@ -77,7 +93,7 @@ def read_biologic(path):
def process_batsmall_data(df, units=None, splice_cycles=None, molecular_weight=None): def process_batsmall_data(df, options=None):
''' Takes BATSMALL-data in the form of a DataFrame and cleans the data up and converts units into desired units. ''' Takes BATSMALL-data in the form of a DataFrame and cleans the data up and converts units into desired units.
Splits up into individual charge and discharge DataFrames per cycle, and outputs a list where each element is a tuple with the Chg and DChg-data. E.g. cycles[10][0] gives the charge data for the 11th cycle. Splits up into individual charge and discharge DataFrames per cycle, and outputs a list where each element is a tuple with the Chg and DChg-data. E.g. cycles[10][0] gives the charge data for the 11th cycle.
@ -94,12 +110,24 @@ def process_batsmall_data(df, units=None, splice_cycles=None, molecular_weight=N
cycles: A list with cycles: A list with
''' '''
required_options = ['splice_cycles', 'molecular_weight', 'reverse_discharge', 'units']
default_options = {'splice_cycles': None, 'molecular_weight': None, 'reverse_discharge': False, 'units': None}
if not options:
options = default_options
else:
for option in required_options:
if option not in options.keys():
options[option] = default_options[option]
# Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new. # Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new.
new_units = set_units(units=units) new_units = set_units(units=options['units'])
old_units = get_old_units(df, kind='batsmall') old_units = get_old_units(df, kind='batsmall')
df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='batsmall') df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='batsmall')
options['units'] = new_units
# Replace NaN with empty string in the Comment-column and then remove all steps where the program changes - this is due to inconsistent values for current # Replace NaN with empty string in the Comment-column and then remove all steps where the program changes - this is due to inconsistent values for current
df[["comment"]] = df[["comment"]].fillna(value={'comment': ''}) df[["comment"]] = df[["comment"]].fillna(value={'comment': ''})
df = df[df["comment"].str.contains("program")==False] df = df[df["comment"].str.contains("program")==False]
@ -126,6 +154,18 @@ def process_batsmall_data(df, units=None, splice_cycles=None, molecular_weight=N
if chg_df.empty and dchg_df.empty: if chg_df.empty and dchg_df.empty:
continue continue
if options['reverse_discharge']:
max_capacity = dchg_df['capacity'].max()
dchg_df['capacity'] = np.abs(dchg_df['capacity'] - max_capacity)
if 'specific_capacity' in df.columns:
max_capacity = dchg_df['specific_capacity'].max()
dchg_df['specific_capacity'] = np.abs(dchg_df['specific_capacity'] - max_capacity)
if 'ions' in df.columns:
max_capacity = dchg_df['ions'].max()
dchg_df['ions'] = np.abs(dchg_df['ions'] - max_capacity)
cycles.append((chg_df, dchg_df)) cycles.append((chg_df, dchg_df))
@ -134,7 +174,7 @@ def process_batsmall_data(df, units=None, splice_cycles=None, molecular_weight=N
return cycles return cycles
def process_neware_data(df, units=None, splice_cycles=None, active_material_weight=None, molecular_weight=None, reverse_discharge=False): def process_neware_data(df, options=None):
""" Takes data from NEWARE in a DataFrame as read by read_neware() and converts units, adds columns and splits into cycles. """ Takes data from NEWARE in a DataFrame as read by read_neware() and converts units, adds columns and splits into cycles.
@ -145,14 +185,27 @@ def process_neware_data(df, units=None, splice_cycles=None, active_material_weig
active_materiale_weight: weight of the active material (in mg) used in the cell. active_materiale_weight: weight of the active material (in mg) used in the cell.
molecular_weight: the molar mass (in g mol^-1) of the active material, to calculate the number of ions extracted. Assumes one electron per Li+/Na+-ion """ molecular_weight: the molar mass (in g mol^-1) of the active material, to calculate the number of ions extracted. Assumes one electron per Li+/Na+-ion """
required_options = ['units', 'active_material_weight', 'molecular_weight', 'reverse_discharge', 'splice_cycles']
default_options = {'units': None, 'active_material_weight': None, 'molecular_weight': None, 'reverse_discharge': False, 'splice_cycles': None}
if not options:
options = default_options
else:
for option in required_options:
if option not in options.keys():
options[option] = default_options[option]
# Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new. # Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new.
new_units = set_units(units=units) new_units = set_units(units=options['units'])
old_units = get_old_units(df=df, kind='neware') old_units = get_old_units(df=df, kind='neware')
df = add_columns(df=df, active_material_weight=active_material_weight, molecular_weight=molecular_weight, old_units=old_units, kind='neware') df = add_columns(df=df, active_material_weight=options['active_material_weight'], molecular_weight=options['molecular_weight'], old_units=old_units, kind='neware')
df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='neware') df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='neware')
options['units'] = new_units
# Creates masks for charge and discharge curves # Creates masks for charge and discharge curves
chg_mask = df['status'] == 'CC Chg' chg_mask = df['status'] == 'CC Chg'
@ -176,7 +229,7 @@ def process_neware_data(df, units=None, splice_cycles=None, active_material_weig
if chg_df.empty and dchg_df.empty: if chg_df.empty and dchg_df.empty:
continue continue
if reverse_discharge: if options['reverse_discharge']:
max_capacity = dchg_df['capacity'].max() max_capacity = dchg_df['capacity'].max()
dchg_df['capacity'] = np.abs(dchg_df['capacity'] - max_capacity) dchg_df['capacity'] = np.abs(dchg_df['capacity'] - max_capacity)
@ -195,19 +248,31 @@ def process_neware_data(df, units=None, splice_cycles=None, active_material_weig
return cycles return cycles
def process_biologic_data(df, units=None, splice_cycles=None, active_material_weight=None, molecular_weight=None, reverse_discharge=False): def process_biologic_data(df, options=None):
required_options = ['units', 'active_material_weight', 'molecular_weight', 'reverse_discharge', 'splice_cycles']
default_options = {'units': None, 'active_material_weight': None, 'molecular_weight': None, 'reverse_discharge': False, 'splice_cycles': None}
if not options:
options = default_options
else:
for option in required_options:
if option not in options.keys():
options[option] = default_options[option]
# Pick out necessary columns # Pick out necessary columns
df = df[['Ns changes', 'Ns', 'time/s', 'Ewe/V', 'Energy charge/W.h', 'Energy discharge/W.h', '<I>/mA', 'Capacity/mA.h', 'cycle number']].copy() df = df[['Ns changes', 'Ns', 'time/s', 'Ewe/V', 'Energy charge/W.h', 'Energy discharge/W.h', '<I>/mA', 'Capacity/mA.h', 'cycle number']].copy()
# Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new. # Complete set of new units and get the units used in the dataset, and convert values in the DataFrame from old to new.
new_units = set_units(units=units) new_units = set_units(units=options['units'])
old_units = get_old_units(df=df, kind='biologic') old_units = get_old_units(df=df, kind='biologic')
df = add_columns(df=df, active_material_weight=active_material_weight, molecular_weight=molecular_weight, old_units=old_units, kind='biologic') df = add_columns(df=df, active_material_weight=options['active_material_weight'], molecular_weight=options['molecular_weight'], old_units=old_units, kind='biologic')
df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='biologic') df = unit_conversion(df=df, new_units=new_units, old_units=old_units, kind='biologic')
options['units'] = new_units
# Creates masks for charge and discharge curves # Creates masks for charge and discharge curves
chg_mask = (df['status'] == 1) & (df['status_change'] != 1) chg_mask = (df['status'] == 1) & (df['status_change'] != 1)
@ -233,7 +298,7 @@ def process_biologic_data(df, units=None, splice_cycles=None, active_material_we
if chg_df.empty and dchg_df.empty: if chg_df.empty and dchg_df.empty:
continue continue
if reverse_discharge: if options['reverse_discharge']:
max_capacity = dchg_df['capacity'].max() max_capacity = dchg_df['capacity'].max()
dchg_df['capacity'] = np.abs(dchg_df['capacity'] - max_capacity) dchg_df['capacity'] = np.abs(dchg_df['capacity'] - max_capacity)
@ -348,8 +413,8 @@ def unit_conversion(df, new_units, old_units, kind):
def set_units(units=None): def set_units(units=None):
# Complete the list of units - if not all are passed, then default value will be used # Complete the list of units - if not all are passed, then default value will be used
required_units = ['time', 'current', 'voltage', 'capacity', 'mass', 'energy'] required_units = ['time', 'current', 'voltage', 'capacity', 'mass', 'energy', 'specific_capacity']
default_units = {'time': 'h', 'current': 'mA', 'voltage': 'V', 'capacity': 'mAh', 'mass': 'g', 'energy': 'mWh'} default_units = {'time': 'h', 'current': 'mA', 'voltage': 'V', 'capacity': 'mAh', 'mass': 'g', 'energy': 'mWh', 'specific_capacity': None}
if not units: if not units:
units = default_units units = default_units
@ -359,6 +424,8 @@ def set_units(units=None):
if unit not in units.keys(): if unit not in units.keys():
units[unit] = default_units[unit] units[unit] = default_units[unit]
units['specific_capacity'] = r'{} {}'.format(units['capacity'], units['mass']) + '$^{-1}$'
return units return units

View file

@ -1,40 +1,373 @@
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from matplotlib.ticker import (MultipleLocator, FormatStrFormatter,AutoMinorLocator)
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import math
import beamtime.electrochemistry as ec
def plot_gc(cycles, which_cycles='all', chg=True, dchg=True, colours=None, x='C', y='U'): def plot_gc(path, kind, options=None):
fig, ax = prepare_gc_plot() # Prepare plot, and read and process data
fig, ax = prepare_gc_plot(options=options)
cycles = ec.io.read_data(path=path, kind=kind, options=options)
if which_cycles == 'all': # Update options
which_cycles = [i for i, c in enumerate(cycles)] required_options = ['x_vals', 'y_vals', 'which_cycles', 'chg', 'dchg', 'colours', 'gradient']
default_options = {'x_vals': 'capacity', 'y_vals': 'voltage', 'which_cycles': 'all', 'chg': True, 'dchg': True, 'colours': None, 'gradient': False}
if not colours: options = update_options(options=options, required_options=required_options, default_options=default_options)
chg_colour = (40/255, 70/255, 75/255) # Dark Slate Gray #28464B
dchg_colour = (239/255, 160/255, 11/255) # Marigold #EFA00B # Update list of cycles to correct indices
update_cycles_list(cycles=cycles, options=options)
colours = generate_colours(cycles=cycles, options=options)
print(len(options['which_cycles']))
print(len(colours))
for i, cycle in enumerate(cycles):
if i in options['which_cycles']:
if options['chg']:
cycle[0].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][0])
if options['dchg']:
cycle[1].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][1])
fig, ax = prettify_gc_plot(fig=fig, ax=ax, options=options)
return cycles, fig, ax
def update_options(options, required_options, default_options):
if not options:
options = default_options
else:
for option in required_options:
if option not in options.keys():
options[option] = default_options[option]
return options
def update_cycles_list(cycles, options):
if not options:
options['which_cycles']
if options['which_cycles'] == 'all':
options['which_cycles'] = [i for i in range(len(cycles))]
elif type(options['which_cycles']) == list:
options['which_cycles'] = [i-1 for i in options['which_cycles']]
# Tuple is used to define an interval - as elements tuples can't be assigned, I convert it to a list here.
elif type(options['which_cycles']) == tuple:
which_cycles = list(options['which_cycles'])
for i, cycle in cycles: if which_cycles[0] <= 0:
if i in which_cycles: which_cycles[0] = 1
if chg:
cycle[0].plot(ax=ax) elif which_cycles[1] < 0:
which_cycles[1] = len(cycles)
options['which_cycles'] = [i-1 for i in range(which_cycles[0], which_cycles[1]+1)]
return options
def prepare_gc_plot(options=None):
# First take care of the options for plotting - set any values not specified to the default values
required_options = ['columns', 'width', 'height', 'format', 'dpi', 'facecolor']
default_options = {'columns': 1, 'width': 14, 'format': 'golden_ratio', 'dpi': None, 'facecolor': 'w'}
# If none are set at all, just pass the default_options
if not options:
options = default_options
options['height'] = options['width'] * (math.sqrt(5) - 1) / 2
options['figsize'] = (options['width'], options['height'])
# If options is passed, go through to fill out the rest.
else:
# Start by setting the width:
if 'width' not in options.keys():
options['width'] = default_options['width']
# Then set height - check options for format. If not given, set the height to the width scaled by the golden ratio - if the format is square, set the same. This should possibly allow for the tweaking of custom ratios later.
if 'height' not in options.keys():
if 'format' not in options.keys():
options['height'] = options['width'] * (math.sqrt(5) - 1) / 2
elif options['format'] == 'square':
options['height'] = options['width']
options['figsize'] = (options['width'], options['height'])
# After height and width are set, go through the rest of the options to make sure that all the required options are filled
for option in required_options:
if option not in options.keys():
options[option] = default_options[option]
fig, ax = plt.subplots(figsize=(options['figsize']), dpi=options['dpi'], facecolor=options['facecolor'])
linewidth = 1*options['columns']
axeswidth = 3*options['columns']
plt.rc('lines', linewidth=linewidth)
plt.rc('axes', linewidth=axeswidth)
return fig, ax
def prettify_gc_plot(fig, ax, options=None):
##################################################################
######################### UPDATE OPTIONS #########################
##################################################################
# Define the required options
required_options = [
'columns',
'xticks', 'yticks',
'show_major_ticks',
'show_minor_ticks',
'xlim', 'ylim',
'hide_x_axis', 'hide_y_axis',
'x_vals', 'y_vals',
'xlabel', 'ylabel',
'units', 'sizes',
'title'
]
# Define the default options
default_options = {
'columns': 1,
'xticks': None,
'yticks': None,
'show_major_ticks': [True, True, True, True],
'show_minor_ticks': [True, True, True, True],
'xlim': None,
'ylim': None,
'hide_x_axis': False,
'hide_y_axis': False,
'x_vals': 'specific_capacity',
'y_vals': 'voltage',
'xlabel': None,
'ylabel': None,
'units': None,
'sizes': None,
'title': None
}
update_options(options, required_options, default_options)
##################################################################
########################## DEFINE SIZES ##########################
##################################################################
# Define the required sizes
required_sizes = [
'labels',
'legend',
'title',
'line', 'axes',
'tick_labels',
'major_ticks', 'minor_ticks']
# Define default sizes
default_sizes = {
'labels': 30*options['columns'],
'legend': 30*options['columns'],
'title': 30*options['columns'],
'line': 3*options['columns'],
'axes': 3*options['columns'],
'tick_labels': 30*options['columns'],
'major_ticks': 20*options['columns'],
'minor_ticks': 10*options['columns']
}
# Initialise dictionary if it doesn't exist
if not options['sizes']:
options['sizes'] = {}
# Update dictionary with default values where none is supplied
for size in required_sizes:
if size not in options['sizes']:
options['sizes'][size] = default_sizes[size]
##################################################################
########################## AXIS LABELS ###########################
##################################################################
if not options['xlabel']:
print(options['x_vals'])
print(options['units'])
options['xlabel'] = prettify_labels(options['x_vals']) + ' [{}]'.format(options['units'][options['x_vals']])
else:
options['xlabel'] = options['xlabel'] + ' [{}]'.format(options['units'][options['x_vals']])
if not options['ylabel']:
options['ylabel'] = prettify_labels(options['y_vals']) + ' [{}]'.format(options['units'][options['y_vals']])
else:
options['ylabel'] = options['ylabel'] + ' [{}]'.format(options['units'][options['y_vals']])
ax.set_xlabel(options['xlabel'], size=options['sizes']['labels'])
ax.set_ylabel(options['ylabel'], size=options['sizes']['labels'])
##################################################################
###################### TICK MARKS & LABELS #######################
##################################################################
ax.tick_params(direction='in', which='major', bottom=options['show_major_ticks'][0], left=options['show_major_ticks'][1], top=options['show_major_ticks'][2], right=options['show_major_ticks'][0], length=options['sizes']['major_ticks'], width=options['sizes']['axes'])
ax.tick_params(direction='in', which='minor', bottom=options['show_minor_ticks'][0], left=options['show_minor_ticks'][1], top=options['show_minor_ticks'][2], right=options['show_minor_ticks'][0], length=options['sizes']['minor_ticks'], width=options['sizes']['axes'])
# DEFINE AND SET TICK DISTANCES
default_ticks = {
'specific_capacity': [100, 50],
'capacity': [0.1, 0.05],
'voltage': [0.5, 0.25]
}
# Set default tick distances for x-axis if not specified
if not options['xticks']:
major_xtick = default_ticks[options['x_vals']][0]
minor_xtick = default_ticks[options['x_vals']][1]
# Otherwise apply user input
else:
major_xtick = options['xticks'][0]
minor_xtick = options['xticks'][1]
# Set default tick distances for x-axis if not specified
if not options['yticks']:
major_ytick = default_ticks[options['y_vals']][0]
minor_ytick = default_ticks[options['y_vals']][1]
# Otherwise apply user input
else:
major_xtick = options['yticks'][0]
minor_xtick = options['yticks'][1]
# Apply values
ax.xaxis.set_major_locator(MultipleLocator(major_xtick))
ax.xaxis.set_minor_locator(MultipleLocator(minor_xtick))
ax.yaxis.set_major_locator(MultipleLocator(major_ytick))
ax.yaxis.set_minor_locator(MultipleLocator(minor_ytick))
# SET FONTSIZE OF TICK LABELS
plt.xticks(fontsize=options['sizes']['tick_labels'])
plt.yticks(fontsize=options['sizes']['tick_labels'])
##################################################################
############################# TITLE ##############################
##################################################################
if options['title']:
ax.set_title(options['title'], size=options['sizes']['title'])
##################################################################
############################# LEGEND #############################
##################################################################
ax.get_legend().remove()
return fig, ax
def prettify_labels(label):
def prepare_gc_plot(figsize=(14,7), dpi=None):
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
labels_dict = {
'capacity': 'Capacity',
'specific_capacity': 'Specific capacity',
'voltage': 'Voltage',
'current': 'Current',
'energy': 'Energy',
}
return fig, ax return labels_dict[label]
def generate_colours(cycles, options):
# Assign colours from the options dictionary if it is defined, otherwise use standard colours.
if options['colours']:
charge_colour = options['colours'][0]
discharge_colour = options['colours'][1]
else:
charge_colour = (40/255, 70/255, 75/255) # Dark Slate Gray #28464B, coolors.co
discharge_colour = (239/255, 160/255, 11/255) # Marigold #EFA00B, coolors.co
# If gradient is enabled, find start and end points for each colour
if options['gradient']:
add_charge = min([(1-x)*0.75 for x in charge_colour])
add_discharge = min([(1-x)*0.75 for x in discharge_colour])
charge_colour_start = charge_colour
charge_colour_end = [x+add_charge for x in charge_colour]
discharge_colour_start = discharge_colour
discharge_colour_end = [x+add_discharge for x in discharge_colour]
# Generate lists of colours
colours = []
for cycle_number in range(0, len(cycles)):
if options['gradient']:
weight_start = (len(cycles) - cycle_number)/len(cycles)
weight_end = cycle_number/len(cycles)
charge_colour = [weight_start*start_colour + weight_end*end_colour for start_colour, end_colour in zip(charge_colour_start, charge_colour_end)]
discharge_colour = [weight_start*start_colour + weight_end*end_colour for start_colour, end_colour in zip(discharge_colour_start, discharge_colour_end)]
colours.append([charge_colour, discharge_colour])
return colours