nafuma/nafuma/electrochemistry/plot.py
2022-10-24 20:58:39 +02:00

747 lines
No EOL
22 KiB
Python

from pickle import MARK
import matplotlib.pyplot as plt
from matplotlib.ticker import (MultipleLocator, FormatStrFormatter,AutoMinorLocator)
import pandas as pd
import numpy as np
import math
import os
import shutil
from PIL import Image
import ipywidgets as widgets
from IPython.display import display
import nafuma.electrochemistry as ec
import nafuma.plotting as btp
import nafuma.auxillary as aux
def plot_gc(data, options=None):
# Update options
default_options = {
'force_reload': False,
'x_vals': 'capacity', 'y_vals': 'voltage',
'which_cycles': 'all',
'limit': None, # Limit line to be drawn
'exclude_cycles': [],
'show_plot': True,
'summary': False,
'charge': True, 'discharge': True,
'colours': None,
'markers': None,
'differentiate_charge_discharge': True,
'gradient': False,
'interactive': False,
'interactive_session_active': False,
'rc_params': {},
'format_params': {},
'save_gif': False,
'save_path': 'animation.gif',
'fps': 1,
'fig': None, 'ax': None,
'edgecolor': plt.rcParams['lines.markeredgecolor'],
'plot_every': 1,
}
options = aux.update_options(options=options, default_options=default_options)
# Read data if not already loaded
if not 'cycles' in data.keys() or options['force_reload']:
data['cycles'] = ec.io.read_data(data=data, options=options)
# Update list of cycles to correct indices
update_cycles_list(data=data, options=options)
if options['interactive']:
options['interactive'], options['interactive_session_active'] = False, True
plot_gc_interactive(data=data, options=options)
return
colours = generate_colours(options=options)
markers = generate_markers(options=options)
if not options['summary']:
if options['show_plot']:
# Prepare plot
if not options['fig'] and not options['ax']:
fig, ax = btp.prepare_plot(options=options)
else:
fig, ax = options['fig'], options['ax']
for i, cycle in enumerate(options['which_cycles']):
if options['charge']:
data['cycles'][cycle][0].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][0])
if options['discharge']:
data['cycles'][cycle][1].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][1])
if options['interactive_session_active']:
update_labels(options, force=True)
else:
update_labels(options)
if options['save_gif'] and not options['interactive_session_active']:
if not os.path.isdir('tmp'):
os.makedirs('tmp')
# Scale image to make GIF smaller
options['format_params']['width'] = 7.5
options['format_params']['height'] = 3
options['format_params']['dpi'] = 200
for i, cycle in enumerate(data['cycles']):
if i in options['which_cycles']:
giffig, gifax = btp.prepare_plot(options=options)
if options['charge']:
cycle[0].plot(x=options['x_vals'], y=options['y_vals'], ax=gifax, c=colours[i][0])
if options['discharge']:
cycle[1].plot(x=options['x_vals'], y=options['y_vals'], ax=gifax, c=colours[i][1])
gifax.text(x=gifax.get_xlim()[1]*0.8, y=3, s=f'{i+1}')
update_labels(options)
giffig, gifax = btp.adjust_plot(fig=giffig, ax=gifax, options=options)
plt.savefig(os.path.join('tmp', str(i+1).zfill(4)+'.png'))
plt.close()
img_paths = [os.path.join('tmp', path) for path in os.listdir('tmp') if path.endswith('png')]
frames = []
for path in img_paths:
frame = Image.open(path)
frames.append(frame)
frames[0].save(options['save_path'], format='GIF', append_images=frames[1:], save_all=True, duration=(1/options['fps'])*1000, loop=0)
shutil.rmtree('tmp')
elif options['summary'] and options['show_plot']:
# Prepare plot
if not options['fig'] and not options['ax']:
fig, ax = btp.prepare_plot(options=options)
else:
fig, ax = options['fig'], options['ax']
mask = []
for i in range(data['cycles'].shape[0]):
if i+1 in options['which_cycles']:
mask.append(True)
else:
mask.append(False)
# Drop the last row if it is midway through a charge in order to avoid mismatch of length of mask and dataset.
if len(mask) > data['cycles'].shape[0]:
del mask[-1]
data['cycles'].drop(data['cycles'].tail(1).index, inplace=True)
# FIXME To begin, the default is that y-values correspond to x-values. This should probably be implemented in more logical and consistent manner in the future.
if options['x_vals'] in ['coulombic_efficiency', 'energy_efficiency']:
data['cycles'].loc[mask].plot(x='cycle', y=options['x_vals'], ax=ax, color=colours[0][1], kind='scatter', s=plt.rcParams['lines.markersize']*10, marker=markers[0], edgecolor=plt.rcParams['lines.markeredgecolor'])
if options['limit']:
ax.axhline(y=options['limit'], ls='--', c='black')
else:
if options['charge']:
yval = 'charge_' + options['x_vals']
data['cycles'].loc[mask].plot(x='cycle', y=yval, ax=ax, color=colours[0][0], kind='scatter', s=plt.rcParams['lines.markersize']*10, marker=markers[0], edgecolor=plt.rcParams['lines.markeredgecolor'])
if options['discharge']:
yval = 'discharge_' + options['x_vals']
data['cycles'].loc[mask].plot(x='cycle', y=yval, ax=ax, color=colours[0][1], kind='scatter', s=plt.rcParams['lines.markersize']*10, marker=markers[1], edgecolor=plt.rcParams['lines.markeredgecolor'])
if options['limit']:
ax.axhline(y=options['limit'], ls='--', c='black')
if options['interactive_session_active']:
update_labels(options, force=True)
else:
update_labels(options)
if options['show_plot']:
fig, ax = btp.adjust_plot(fig=fig, ax=ax, options=options)
return data['cycles'], fig, ax
else:
return data['cycles'], None, None
def plot_gc_interactive(data, options):
w = widgets.interactive(btp.ipywidgets_update, func=widgets.fixed(plot_gc), data=widgets.fixed(data), options=widgets.fixed(options),
charge=widgets.ToggleButton(value=True),
discharge=widgets.ToggleButton(value=True),
x_vals=widgets.Dropdown(options=['specific_capacity', 'capacity', 'ions', 'voltage', 'time', 'energy'], value='specific_capacity', description='X-values')
)
options['widget'] = w
display(w)
def plot_cv(data, options):
# Update options
default_options = {
'force_reload': False,
'x_vals': 'voltage', 'y_vals': 'current',
'which_cycles': 'all',
'limit': None, # Limit line to be drawn
'exclude_cycles': [],
'show_plot': True,
'charge': True, 'discharge': True,
'colours': None,
'differentiate_charge_discharge': True,
'gradient': False,
'interactive': False,
'interactive_session_active': False,
'rc_params': {},
'format_params': {},
'save_gif': False,
'save_path': 'animation.gif',
'fps': 1,
'plot_every': 1,
'fig': None,
'ax': None
}
options = aux.update_options(options=options, default_options=default_options)
# Read data if not already loaded
if not 'cycles' in data.keys() or options['force_reload']:
data['cycles'] = ec.io.read_data(data=data, options=options)
# Update list of cycles to correct indices
update_cycles_list(data=data, options=options)
colours = generate_colours(options=options)
if options['show_plot']:
# Prepare plot
if not options['fig'] and not options['ax']:
fig, ax = btp.prepare_plot(options=options)
else:
fig, ax = options['fig'], options['ax']
for i, cycle in enumerate(options['which_cycles']):
if options['charge']:
data['cycles'][cycle][0].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][0])
if options['discharge']:
data['cycles'][cycle][1].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][1])
# for i, cycle in enumerate(data['cycles']):
# if i in options['which_cycles']:
# if options['charge']:
# cycle[0].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][0])
# if options['discharge']:
# cycle[1].plot(x=options['x_vals'], y=options['y_vals'], ax=ax, c=colours[i][1])
update_labels(options)
if options['save_gif'] and not options['interactive_session_active']:
if not os.path.isdir('tmp'):
os.makedirs('tmp')
# Scale image to make GIF smaller
options['format_params']['width'] = 7.5
options['format_params']['height'] = 3
options['format_params']['dpi'] = 200
for i, cycle in enumerate(data['cycles']):
if i in options['which_cycles']:
giffig, gifax = btp.prepare_plot(options=options)
if options['charge']:
cycle[0].plot(x=options['x_vals'], y=options['y_vals'], ax=gifax, c=colours[i][0])
if options['discharge']:
cycle[1].plot(x=options['x_vals'], y=options['y_vals'], ax=gifax, c=colours[i][1])
gifax.text(x=gifax.get_xlim()[1]*0.8, y=3, s=f'{i+1}')
update_labels(options)
giffig, gifax = btp.adjust_plot(fig=giffig, ax=gifax, options=options)
plt.savefig(os.path.join('tmp', str(i+1).zfill(4)+'.png'))
plt.close()
img_paths = [os.path.join('tmp', path) for path in os.listdir('tmp') if path.endswith('png')]
frames = []
for path in img_paths:
frame = Image.open(path)
frames.append(frame)
frames[0].save(options['save_path'], format='GIF', append_images=frames[1:], save_all=True, duration=(1/options['fps'])*1000, loop=0)
shutil.rmtree('tmp')
if options['show_plot']:
fig, ax = btp.adjust_plot(fig=fig, ax=ax, options=options)
return data['cycles'], fig, ax
else:
return data['cycles'], None, None
def update_labels(options, force=False):
if 'xlabel' not in options.keys() or force:
options['xlabel'] = options['x_vals'].capitalize().replace('_', ' ')
if 'ylabel' not in options.keys() or force:
options['ylabel'] = options['y_vals'].capitalize().replace('_', ' ')
if 'xunit' not in options.keys() or force:
if options['x_vals'] == 'capacity':
options['xunit'] = options['units']['capacity']
elif options['x_vals'] == 'specific_capacity':
options['xunit'] = f"{options['units']['capacity']} {options['units']['mass']}$^{{-1}}$"
elif options['x_vals'] == 'time':
options['xunit'] = options['units']['time']
elif options['x_vals'] == 'ions':
options['xunit'] = None
if 'yunit' not in options.keys() or force:
if options['y_vals'] == 'voltage':
options['yunit'] = options['units']['voltage']
def update_cycles_list(data, options: dict) -> None:
if options['which_cycles'] == 'all':
options['which_cycles'] = [i for i in range(len(data['cycles']))]
elif isinstance(options['which_cycles'], list):
cycles =[]
for cycle in options['which_cycles']:
if isinstance(cycle, int):
cycles.append(cycle-1)
elif isinstance(cycle, tuple):
interval = [i-1 for i in range(cycle[0], cycle[1]+1)]
cycles.extend(interval)
options['which_cycles'] = cycles
# Tuple is used to define an interval - as elements tuples can't be assigned, I convert it to a list here.
elif isinstance(options['which_cycles'], tuple):
which_cycles = list(options['which_cycles'])
if which_cycles[0] <= 0:
which_cycles[0] = 1
elif which_cycles[1] < 0:
which_cycles[1] = len(options['which_cycles'])
options['which_cycles'] = [i-1 for i in range(which_cycles[0], which_cycles[1]+1)]
for i, cycle in enumerate(options['which_cycles']):
if cycle in options['exclude_cycles']:
del options['which_cycles'][i]
options['which_cycles'] = options['which_cycles'][::options['plot_every']]
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',
'positions',
'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,
'positions': {'xaxis': 'bottom', 'yaxis': 'left'},
'x_vals': 'specific_capacity', 'y_vals': 'voltage',
'xlabel': None, 'ylabel': None,
'units': {'capacity': 'mAh', 'specific_capacity': r'mAh g$^{-1}$', 'time': 's', 'current': 'mA', 'energy': 'mWh', 'mass': 'g', 'voltage': 'V'},
'sizes': None,
'title': None
}
aux.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']:
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
from . import unit_tables
# Define default ticks and scale to desired units
default_ticks = {
'specific_capacity': [100 * (unit_tables.capacity()['mAh'].loc[options['units']['capacity']] / unit_tables.mass()['g'].loc[options['units']['mass']]), 50 * (unit_tables.capacity()['mAh'].loc[options['units']['capacity']] / unit_tables.mass()['g'].loc[options['units']['mass']])],
'capacity': [0.1 * (unit_tables.capacity()['mAh'].loc[options['units']['capacity']]), 0.05 * (unit_tables.capacity()['mAh'].loc[options['units']['capacity']])],
'voltage': [0.5 * (unit_tables.voltage()['V'].loc[options['units']['voltage']]), 0.25 * (unit_tables.voltage()['V'].loc[options['units']['voltage']])],
'time': [10 * (unit_tables.time()['h'].loc[options['units']['time']]), 5 * (unit_tables.time()['h'].loc[options['units']['time']])]
}
if options['positions']['yaxis'] == 'right':
ax.yaxis.set_label_position("right")
ax.yaxis.tick_right()
# 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_ytick = options['yticks'][0]
minor_ytick = 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'])
##################################################################
########################## AXES LIMITS ###########################
##################################################################
if options['xlim']:
plt.xlim(options['xlim'])
if options['ylim']:
plt.ylim(options['ylim'])
##################################################################
############################# TITLE ##############################
##################################################################
if options['title']:
ax.set_title(options['title'], size=options['sizes']['title'])
##################################################################
############################# LEGEND #############################
##################################################################
if ax.get_legend():
ax.get_legend().remove()
return fig, ax
def prettify_labels(label):
labels_dict = {
'capacity': 'Capacity',
'specific_capacity': 'Specific capacity',
'voltage': 'Voltage',
'current': 'Current',
'energy': 'Energy',
'time': 'Time'
}
return labels_dict[label]
def generate_colours(options):
default_options = {
'gradient_colours': None,
}
aux.update_options(options=options, default_options=default_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]
if isinstance(charge_colour, tuple):
charge_colour = list(charge_colour)
if isinstance(discharge_colour, tuple):
discharge_colour = list(discharge_colour)
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 not options['differentiate_charge_discharge']:
discharge_colour = charge_colour
# If gradient is enabled, find start and end points for each colour
if options['gradient']:
if not options['gradient_colours']:
options['gradient_colours'] = [[None, None], [None, None]]
add_charge = min([(1-x)*0.75 for x in charge_colour])
add_discharge = min([(1-x)*0.75 for x in discharge_colour])
options['gradient_colours'][0][0] = charge_colour
options['gradient_colours'][0][1] = [x+add_charge for x in charge_colour]
options['gradient_colours'][1][0] = discharge_colour
options['gradient_colours'][1][1] = [x+add_discharge for x in discharge_colour]
# Generate lists of colours
colours = []
if len(charge_colour) != len(options['which_cycles']):
if options['gradient']:
options['number_of_colours'] = len(options['which_cycles'])
charge_colours = btp.mix_colours(colour1=options['gradient_colours'][0][0], colour2=options['gradient_colours'][0][1], options=options)
discharge_colours = btp.mix_colours(colour1=options['gradient_colours'][1][0], colour2=options['gradient_colours'][1][1], options=options)
for chg, dchg in zip(charge_colours, discharge_colours):
colours.append([chg, dchg])
else:
for i in options['which_cycles']:
colours.append([charge_colour, discharge_colour])
else:
for chg, dchg in zip(charge_colour, discharge_colour):
colours.append([chg, dchg])
return colours
def generate_markers(options):
if not options['markers']:
markers = ['o', 'v']
else:
markers = [options['markers'][0], options['markers'][1]]
return markers
def get_tickmarks(df: pd.DataFrame, ticks: list, value: str, exclude=None):
min_val = df[value].min()
max_val = df[value].max()
# Get major ticks
major_ticks = [np.round((min_val + ticks[0]*i),2) for i in range(int(np.floor((max_val-min_val)/ticks[0]))+1)]
major_ticks.append(np.round(max_val, 2))
major_ticks = aux.get_unique(major_ticks)
major_ticklabels = [i*ticks[0] for i in range(len(major_ticks)-1)]
major_ticklabels.append(np.round((max_val-min_val),1))
if exclude:
for i, tick in enumerate(major_ticklabels):
if tick in exclude:
del major_ticks[i]
del major_ticklabels[i]
# Get minor ticks
minor_ticks = [np.round((min_val + ticks[1]*i),2) for i in range(int(np.floor((max_val-min_val)/ticks[1]))+1) if np.round((min_val + ticks[1]*i),2) not in major_ticks]
minor_ticklabels = [np.round(tick - min_val, 2) for tick in minor_ticks]
return major_ticks, major_ticklabels, minor_ticks, minor_ticklabels
def assign_tickmarks(dfs: list, options, fig, ax, exclude=None):
major_ticks, major_ticklabels, minor_ticks = [], [], []
if not exclude:
exclude = [[None, None] for i in range(len(options['which_cycles']))]
for i, cycle in enumerate(options['which_cycles']):
#Get ticks from charge cycle
major_tick, major_ticklabel, minor_tick, minor_ticklabel = ec.plot.get_tickmarks(dfs[cycle][0], ticks=options['x_tick_locators'], value=options['x_vals'], exclude=exclude[i][0])
major_ticks += major_tick
major_ticklabels += major_ticklabel
minor_ticks += minor_tick
# Get ticks from discharge cycle
major_tick, major_ticklabel, minor_tick, minor_ticklabel = ec.plot.get_tickmarks(dfs[cycle][1], ticks=[1, 0.25], value='ions', exclude=exclude[i][1])
major_ticks += major_tick
major_ticklabels += major_ticklabel
minor_ticks += minor_tick
ax.set_xticks(major_ticks, minor=False)
ax.set_xticklabels(major_ticklabels)
ax.set_xticks(minor_ticks, minor=True)
return fig, ax