Source code for pygaps.graphing.isotherm_graphs

"""Functions for plotting and comparing isotherms."""

import math
import typing as t
from collections import abc
from itertools import cycle

import matplotlib as mpl
import matplotlib.pyplot as plt
from cycler import cycler

from pygaps import logger
from pygaps.graphing.labels import label_lgd
from pygaps.graphing.labels import label_units_dict
from pygaps.graphing.mpl_styles import BASE_STYLE
from pygaps.graphing.mpl_styles import ISO_MARKERS
from pygaps.graphing.mpl_styles import ISO_STYLE
from pygaps.graphing.mpl_styles import Y1_COLORS
from pygaps.graphing.mpl_styles import Y2_COLORS
from pygaps.utilities.exceptions import GraphingError
from pygaps.utilities.exceptions import ParameterError

#: list of branch types
_BRANCH_TYPES = {
    "ads": (True, False),
    "des": (False, True),
    "all": (True, True),
}


[docs]@mpl.rc_context(BASE_STYLE) @mpl.rc_context(ISO_STYLE) def plot_iso( isotherms, ax=None, x_data: str = 'pressure', y1_data: str = 'loading', y2_data: str = None, branch: str = "all", x_range: t.Tuple[float, float] = (None, None), y1_range: t.Tuple[float, float] = (None, None), y2_range: t.Tuple[float, float] = (None, None), x_points: t.Iterable[float] = None, y1_points: t.Iterable[float] = None, material_basis: str = None, material_unit: str = None, loading_basis: str = None, loading_unit: str = None, pressure_mode: str = None, pressure_unit: str = None, logx: bool = False, logy1: bool = False, logy2: bool = False, color: t.Union[bool, str, t.Iterable[str]] = True, marker: t.Union[bool, str, t.Iterable[str]] = True, y1_line_style: dict = None, y2_line_style: dict = None, lgd_keys: list = None, lgd_pos: str = 'best', save_path: str = None, ): """ Plot the isotherm(s) provided on a single graph. Parameters ---------- isotherms : PointIsotherms or list of Pointisotherms An isotherm or iterable of isotherms to be plotted. ax : matplotlib axes object, default None The axes object where to plot the graph if a new figure is not desired. x_data : str Key of data to plot on the x axis. Defaults to 'pressure'. y1_data : tuple Key of data to plot on the left y axis. Defaults to 'loading'. y2_data : tuple Key of data to plot on the right y axis. Defaults to None. branch : str Which branch to display, adsorption ('ads'), desorption ('des'), or both ('all'). x_range : tuple Range for data on the x axis. eg: (0, 1). Is applied to each isotherm, in the unit/mode/basis requested. y1_range : tuple Range for data on the regular y axis. eg: (0, 1). Is applied to each isotherm, in the unit/mode/basis requested. y2_range : tuple Range for data on the secondary y axis. eg: (0, 1). Is applied to each isotherm, in the unit/mode/basis requested. x_points : tuple Specific points of pressure where to evaluate an isotherm. Assumes x=pressure. y1_points : tuple Specific points of loading where to evaluate an isotherm. Assumes y1=loading. material_basis : str, optional Whether the adsorption is read in terms of either 'per volume' or 'per mass'. material_unit : str, optional Unit of loading, otherwise first isotherm value is used. loading_basis : str, optional Loading basis, otherwise first isotherm value is used. loading_unit : str, optional Unit of loading, otherwise first isotherm value is used. pressure_mode : str, optional The pressure mode, either absolute pressures or relative in the form of p/p0, otherwise first isotherm value is used. pressure_unit : str, optional Unit of pressure, otherwise first isotherm value is used. logx : bool Whether the graph x axis should be logarithmic. logy1 : bool Whether the graph y1 axis should be logarithmic. logy2 : bool Whether the graph y2 axis should be logarithmic. color : bool, int, list, optional If a boolean, the option controls if the graph is coloured or grayscale. Grayscale graphs are usually preferred for publications or print media. Otherwise, give a list of matplotlib colours or a number of colours to repeat in the cycle. marker : bool, int, list, optional Whether markers should be used to denote isotherm points. If an int, it will be the number of markers used. Otherwise, give a list of matplotlib markers or a number of markers to repeat in the cycle. y1_line_style : dict A dictionary that will be passed into the matplotlib plot() function. Applicable for left axis. y2_line_style : dict A dictionary that will be passed into the matplotlib plot() function. Applicable for right axis. lgd_keys : iterable The components of the isotherm which are displayed on the legend. For example pass ['material', 'adsorbate'] to have the legend labels display only these two components. Works with any isotherm properties and with 'branch' and 'key', the isotherm branch and the y-axis key respectively. Defaults to 'material' and 'adsorbate'. lgd_pos : [None, Matplotlib legend classifier, 'out bottom', 'out top', 'out left', out right] Specify to have the legend position outside the figure (out...) or inside the plot area itself (as determined by Matplotlib). Defaults to 'best'. save_path : str, optional Whether to save the graph or not. If a path is provided, then that is where the graph will be saved. Returns ------- axes : matplotlib.axes.Axes or numpy.ndarray of them """ ####################################### # # Initial checks # Make iterable if not already if not isinstance(isotherms, abc.Iterable): isotherms = (isotherms, ) else: isotherms = list(isotherms) # Check for plot validity if None in [x_data, y1_data]: raise ParameterError( "Specify a plot type to graph" " e.g. x_data=\'loading\', y1_data=\'pressure\'" ) # Check if required keys are present in isotherms if any(x_data not in _get_keys(isotherm) for isotherm in isotherms): raise GraphingError(f"One of the isotherms supplied does not have {x_data} data.") if any(y1_data not in _get_keys(isotherm) for isotherm in isotherms): raise GraphingError(f"One of the isotherms supplied does not have {y1_data} data.") if y2_data: if all(y2_data not in _get_keys(isotherm) for isotherm in isotherms): raise GraphingError(f"None of the isotherms supplied have {y2_data} data") if any(y2_data not in _get_keys(isotherm) for isotherm in isotherms): logger.warning(f"Some isotherms do not have {y2_data} data") # Store which branches will be displayed if not branch: raise ParameterError("Specify a branch to display" " e.g. branch=\'ads\'") if branch not in _BRANCH_TYPES: raise GraphingError( "The supplied branch type is not valid." f"Viable types are {_BRANCH_TYPES}" ) ads, des = _BRANCH_TYPES[branch] # Ensure iterable y1_line_style = y1_line_style if y1_line_style else {} y2_line_style = y2_line_style if y2_line_style else {} lgd_keys = lgd_keys if lgd_keys else [] # Pack other parameters data_params = dict( x_data=x_data, y1_data=y1_data, y2_data=y2_data, x_points=x_points, y1_points=y1_points, ) unit_params = dict( pressure_mode=pressure_mode if pressure_mode else isotherms[0].pressure_mode, pressure_unit=pressure_unit if pressure_unit else isotherms[0].pressure_unit, loading_basis=loading_basis if loading_basis else isotherms[0].loading_basis, loading_unit=loading_unit if loading_unit else isotherms[0].loading_unit, material_basis=material_basis if material_basis else isotherms[0].material_basis, material_unit=material_unit if material_unit else isotherms[0].material_unit, ) range_params = dict( x_range=x_range, y1_range=y1_range, y2_range=y2_range, x_points=x_points, y1_points=y1_points ) log_params = dict( logx=logx, logy1=logy1, logy2=logy2, ) ####################################### # # Settings and graph generation # # Generate or assign the figure and the axes if ax: ax1 = ax fig = ax1.get_figure() else: fig = plt.figure() ax1 = fig.add_subplot(111) # Create second axes object, populate it if required ax2 = ax1.twinx() if y2_data else None # Get a cycling style for the graph # # Color styling y1_colors = _get_colors(color, Y1_COLORS) y2_colors = _get_colors(color, Y2_COLORS) y1_color_cy = cycler('color', y1_colors) y2_color_cy = cycler('color', y2_colors) # # Marker styling markers = _get_markers(marker) y1_marker_cy = cycler('marker', markers) y2_marker_cy = cycler('marker', markers[::-1]) # # Combine cycles cycle_compose = True if marker else False pc_y1 = _cycle_compose(y1_marker_cy, y1_color_cy, cycle_compose) pc_y2 = _cycle_compose(y2_marker_cy, y2_color_cy, cycle_compose) # Labels ax1.set_xlabel(label_units_dict(x_data, unit_params)) ax1.set_ylabel(label_units_dict(y1_data, unit_params)) if y2_data: ax2.set_ylabel(label_units_dict(y2_data, unit_params)) ##################################### # # Actual plotting # # Plot the data for isotherm in isotherms: # Line styles for the current isotherm y1_ls = next(pc_y1) y2_ls = next(pc_y2) y1_ls.update(y1_line_style) y2_ls.update(y2_line_style) # If there's an adsorption branch, plot it iso_has_ads = isotherm.has_branch('ads') iso_has_des = isotherm.has_branch('des') if ads and iso_has_ads: # Points x1_p, y1_p, x2_p, y2_p = _get_data( isotherm, 'ads', data_params=data_params, unit_params=unit_params, range_params=range_params, ) # Plot line 1 y1_lbl = label_lgd(isotherm, lgd_keys, 'ads', y1_data) ax1.plot(x1_p, y1_p, label=y1_lbl, **y1_ls) # Plot line 2 if y2_data and y2_p is not None: y2_lbl = label_lgd(isotherm, lgd_keys, 'ads', y2_data) ax2.plot(x2_p, y2_p, label=y2_lbl, **y2_ls) # Switch to desorption linestyle (dotted, white marker) y1_ls['markerfacecolor'] = 'white' y1_ls['linestyle'] = '--' y2_ls['markerfacecolor'] = 'white' # If there's a desorption branch, plot it if des and iso_has_des: # Points x1_p, y1_p, x2_p, y2_p = _get_data( isotherm, 'des', data_params=data_params, unit_params=unit_params, range_params=range_params, ) # Plot line 1 if branch == 'all' and 'branch' not in lgd_keys and iso_has_ads: y1_lbl = '' else: y1_lbl = label_lgd(isotherm, lgd_keys, 'des', y1_data) ax1.plot(x1_p, y1_p, label=y1_lbl, **y1_ls) # Plot line 2 if y2_data and y2_p is not None: if branch == 'all' and 'branch' not in lgd_keys and iso_has_ads: y2_lbl = '' else: y2_lbl = label_lgd(isotherm, lgd_keys, 'des', y2_data) ax2.plot(x2_p, y2_p, label=y2_lbl, **y2_ls) ##################################### # # Final settings _final_styling( fig, ax1, ax2, log_params, range_params, lgd_pos, save_path, ) if ax2: return (ax1, ax2) return ax1
def _get_keys(iso): return ['loading', 'pressure'] + iso.other_keys def _get_colors(color, palette): if color: if isinstance(color, bool): return palette if isinstance(color, int): ncol = len(palette) if color > len(palette) else color return palette[:ncol] if isinstance(color, abc.Iterable): return color raise ParameterError("Unknown ``color`` parameter type.") return ['black', 'grey', 'silver'] def _get_markers(marker): if marker: if isinstance(marker, bool): return ISO_MARKERS if isinstance(marker, int): nmark = len(ISO_MARKERS) if marker > len(ISO_MARKERS) else marker return ISO_MARKERS[:nmark] if isinstance(marker, abc.Iterable): return marker raise ParameterError("Unknown ``marker`` parameter type.") return [] def _cycle_compose(cy_1, cy_2, cycle_compose): if cycle_compose: return cycle(cy_1 * cy_2) l_1 = len(cy_1) l_2 = len(cy_2) if l_1 == 0: return cycle(cy_2) if l_2 == 0: return cycle(cy_1) if l_1 > l_2: return cycle(cy_1 + (cy_2 * math.ceil(l_1 / l_2))[:l_1]) return cycle(cy_2 + (cy_1 * math.ceil(l_2 / l_1))[:l_2]) def _get_data( isotherm, branch, data_params, unit_params, range_params, ): """Plot the y1 data and y2 data of each branch.""" if data_params['x_points'] is None and data_params['y1_points'] is None: # Data X x1_p = _get_data_column( isotherm=isotherm, data_name=data_params['x_data'], branch=branch, unit_params=unit_params, data_range=range_params['x_range'], ) # Data line 1 y1_p = _get_data_column( isotherm=isotherm, data_name=data_params['y1_data'], branch=branch, unit_params=unit_params, data_range=range_params['y1_range'], ) x1_p, y1_p = x1_p.align(y1_p, join='inner') # Data line 2 x2_p = None y2_p = None if data_params['y2_data'] and data_params['y2_data'] in _get_keys(isotherm): y2_p = _get_data_column( isotherm, data_name=data_params['y2_data'], branch=branch, unit_params=unit_params, data_range=range_params['y2_range'], ) x2_p, y2_p = x1_p.align(y2_p, join='inner') else: if data_params['x_points'] is not None: x1_p = data_params['x_points'] y1_p = _get_data_column( isotherm=isotherm, data_name=data_params['y1_data'], branch=branch, unit_params=unit_params, data_range=range_params['y1_range'], data_points=data_params['x_points'], ) elif data_params['y1_points'] is not None: x1_p = _get_data_column( isotherm=isotherm, data_name=data_params['x_data'], branch=branch, unit_params=unit_params, data_range=range_params['x_range'], data_points=data_params['y1_points'], ) y1_p = data_params['y1_points'] x2_p = None y2_p = None return x1_p, y1_p, x2_p, y2_p def _get_data_column( isotherm, data_name, branch, unit_params, data_range=None, data_points=None, ): """Get different data from an isotherm.""" caller_dict = {'branch': branch} if data_name == 'pressure': caller_dict['pressure_mode'] = unit_params['pressure_mode'] caller_dict['pressure_unit'] = unit_params['pressure_unit'] if data_points is not None: return isotherm.pressure_at(data_points, **caller_dict) return isotherm.pressure(limits=data_range, indexed=True, **caller_dict) if data_name == 'loading': caller_dict['loading_basis'] = unit_params['loading_basis'] caller_dict['loading_unit'] = unit_params['loading_unit'] caller_dict['material_basis'] = unit_params['material_basis'] caller_dict['material_unit'] = unit_params['material_unit'] if data_points is not None: return isotherm.loading_at(data_points, **caller_dict) return isotherm.loading(limits=data_range, indexed=True, **caller_dict) return isotherm.other_data(data_name, limits=data_range, indexed=True, **caller_dict) def _final_styling( fig, ax1, ax2, log_params, range_params, lgd_pos, save_path, ): """Axes scales and limits, legend and graph saving.""" # Convert the axes into logarithmic if required if log_params['logx']: ax1.set_xscale('log') if log_params['logy1']: ax1.set_yscale('log') if ax2 and log_params['logy2']: ax2.set_yscale('log') # Axes range settings ax1.set_xlim(range_params['x_range']) ax1.set_ylim(range_params['y1_range']) if ax2: ax2.set_ylim(range_params['y2_range']) # Add the legend bbox_extra_artists = [] if lgd_pos is not None: # Get handles and combine them lines, labels = ax1.get_legend_handles_labels() if ax2: lines2, labels2 = ax2.get_legend_handles_labels() lines = lines + lines2 labels = labels + labels2 # Add the option for a large figure legend if lgd_pos in ['out left', 'out right', 'out bottom', 'out top']: lgd_style = {'bbox_transform': fig.transFigure} if lgd_pos == 'out top': lgd_style['bbox_to_anchor'] = (0.5, 1) lgd_style['loc'] = 'lower center' lgd_style['ncol'] = 2 elif lgd_pos == 'out bottom': lgd_style['bbox_to_anchor'] = (0.5, 0) lgd_style['loc'] = 'upper center' lgd_style['ncol'] = 2 elif lgd_pos == 'out right': lgd_style = {} lgd_style['bbox_to_anchor'] = (1, 0.5) lgd_style['loc'] = 'center left' elif lgd_pos == 'out left': lgd_style = {} lgd_style['bbox_to_anchor'] = (0, 0.5) lgd_style['loc'] = 'center right' lgd = fig.legend(lines, labels, **lgd_style) else: lgd = ax1.legend(lines, labels, loc=lgd_pos) bbox_extra_artists.append(lgd) # Fix size of graphs fig.tight_layout() # Save if desired if save_path: fig.savefig( save_path, bbox_extra_artists=bbox_extra_artists, bbox_inches='tight', dpi=300, )