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
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):
''' 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.
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
'''
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.
new_units = set_units(units=units)
new_units = set_units(units=options['units'])
old_units = get_old_units(df, 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
df[["comment"]] = df[["comment"]].fillna(value={'comment': ''})
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:
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))
@ -134,7 +174,7 @@ def process_batsmall_data(df, units=None, splice_cycles=None, molecular_weight=N
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.
@ -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.
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.
new_units = set_units(units=units)
new_units = set_units(units=options['units'])
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')
options['units'] = new_units
# Creates masks for charge and discharge curves
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:
continue
if reverse_discharge:
if options['reverse_discharge']:
max_capacity = dchg_df['capacity'].max()
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
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
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.
new_units = set_units(units=units)
new_units = set_units(units=options['units'])
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')
options['units'] = new_units
# Creates masks for charge and discharge curves
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:
continue
if reverse_discharge:
if options['reverse_discharge']:
max_capacity = dchg_df['capacity'].max()
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):
# Complete the list of units - if not all are passed, then default value will be used
required_units = ['time', 'current', 'voltage', 'capacity', 'mass', 'energy']
default_units = {'time': 'h', 'current': 'mA', 'voltage': 'V', 'capacity': 'mAh', 'mass': 'g', 'energy': 'mWh'}
required_units = ['time', 'current', 'voltage', 'capacity', 'mass', 'energy', 'specific_capacity']
default_units = {'time': 'h', 'current': 'mA', 'voltage': 'V', 'capacity': 'mAh', 'mass': 'g', 'energy': 'mWh', 'specific_capacity': None}
if not units:
units = default_units
@ -359,6 +424,8 @@ def set_units(units=None):
if unit not in units.keys():
units[unit] = default_units[unit]
units['specific_capacity'] = r'{} {}'.format(units['capacity'], units['mass']) + '$^{-1}$'
return units

View file

@ -1,40 +1,373 @@
import matplotlib.pyplot as plt
from matplotlib.ticker import (MultipleLocator, FormatStrFormatter,AutoMinorLocator)
import pandas as pd
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':
which_cycles = [i for i, c in enumerate(cycles)]
# Update options
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:
chg_colour = (40/255, 70/255, 75/255) # Dark Slate Gray #28464B
dchg_colour = (239/255, 160/255, 11/255) # Marigold #EFA00B
options = update_options(options=options, required_options=required_options, default_options=default_options)
# 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 i in which_cycles:
if chg:
cycle[0].plot(ax=ax)
if which_cycles[0] <= 0:
which_cycles[0] = 1
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 prepare_gc_plot(figsize=(14,7), dpi=None):
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
def prettify_labels(label):
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