Source code for fragmentmnp.output

"""
FRAGMENT-MNP Output
===================

Provides functionality for processing and visulalising model output data.
"""
import uuid
import numpy as np
import numpy.typing as npt
import matplotlib.pyplot as plt


[docs]class FMNPOutput(): """ Class that holds output data from the model and provides plotting functionalities. Parameters ---------- t : np.ndarray, shape (n_timesteps,) Time series over which the model was run c: np.ndarray, shape (n_size_classes, n_timesteps) Mass concentrations for each size class over the time series n : np.ndarray, shape (n_size_classes, n_timesteps) Particle number concentrations for each size class over the time series c_diss : np.ndarray, shape (n_size_classes, n_timesteps) Mass concentrations of dissolved organics n_diss : np.ndarray, shape (n_size_classes, n_timesteps) Particle number concentrations lost from size classes due to dissolution soln : Bunch object from scipy.integrate.solve_ivp return The solution to the model ODE, passed directly from the scipy.integrate.solve_ivp method psd : np.ndarray, shape (n_size_classes, ) Particle size distribution - the average diameters of each of the particle size classes """ __slots__ = ['t', 'c', 'n', 'c_diss', 'n_diss', 'n_timesteps', 'n_size_classes', 'soln', 'psd', 'id'] def __init__(self, t: npt.NDArray, c: npt.NDArray, n: npt.NDArray, c_diss: npt.NDArray, n_diss: npt.NDArray, soln, psd, id=None) -> None: """ Initialise the output data object """ # Set the main output data self.t = t self.c = c self.n = n self.c_diss = c_diss self.n_diss = n_diss self.soln = soln self.psd = psd # Save the number of timesteps and size classes self.n_timesteps = self.t.shape[0] self.n_size_classes = self.c.shape[0] # Set the ID based on what we've been given, or # give a unique ID if id is None: self.id = uuid.uuid4() else: self.id = id
[docs] def plot(self, type: str = 'mass_conc', plot_dissolution: bool = False, log_yaxis=False, units=None, cmap='viridis', show_legend=True, size_classes_to_plot=None): """ Plot the output data by choosing from a number of pre-defined plot types. Parameters ---------- type : str, default='mass_conc' Tells the function what type of plot to produce. Either `particle_number_conc` or `mass_conc`. plot_dissolution : bool, default=False Should dissolution be plotted on a separate y-axis log_yaxis: bool or str, default=False True and "log" plots the y axis on a log scale, "symlog" plots the y axis on a symlog scale (useful if the data contain zeros), and False plots the y axis on a linear scale units: dict or str, default=None Units to be used in axis labels. Must either be "SI" to use SI units, "dim" to use dimensional labels ("mass", "volume", etc), or a dictionary containing elements for `mass`, `volume` and `time`. E.g. `units={'mass': 'kg', 'volume': 'm3', 'time': 's', 'length': 'nm'}`. Note that these units are used purely for axis labelling and are not used to modify the model output data (which is unit agnostic). If `None` (the default), no units are added to labels. cmap: str, default='viridis' The colormap to use for the plot. Must be one of the colormaps `available in matplotlib <https://matplotlib.org/stable/gallery/color/colormap_reference.html>`. Note that these are case-sensitive. show_legend: bool, default=True Should size classes be shown on a legend? size_classes_to_plot: list of ints, default=None Only plot specific size classes by providing a list of size class indices to plot, where 0 is the index of the smallest size class. By default, all size classes are plotted. Returns ------- (matplotlib.figure.Figure, matplotlib.axes.Axes) Matplotlib figure and axes objects for the plot """ unit_labels = self._construct_units(units) # Are we plotting number or mass concentrations? Set the # y values and labels accordingly if type == 'particle_number_conc': # Set the yaxis label ylabel = 'Particle number concentration' if unit_labels is not None: ylabel += f' [{unit_labels["number_conc"]}]' # Set the yaxis values, transposed to pass to mpl yvals = self.n.T elif type == 'mass_conc': ylabel = 'Mass concentration' if unit_labels is not None: ylabel += f' [{unit_labels["mass_conc"]}]' yvals = self.c.T else: # Raise an error if an invalid plot type is given raise ValueError(f'Invalid option for plot `type`: {type}. ' 'Should be `particle_number_conc` or' '`mass_conc`.') # Only plot the size classes we have been asked to if size_classes_to_plot is not None: yvals = yvals[:, size_classes_to_plot] # Set the xaxis label xlabel = 'Time' if unit_labels is not None: xlabel += f' [{unit_labels["time"]}]' # Change the colourmap to something useful (Viridis) cmap_ = plt.colormaps[cmap] plt.rcParams['axes.prop_cycle'] = \ plt.cycler('color', cmap_(np.linspace(0, 1, yvals.shape[1]))) # Create the figure and axes - we need to this because we need to # add a second axis for the dissolution data fig, ax1 = plt.subplots() # Construct the xlabel with(out) units xlabel = 'Time' if unit_labels is not None: xlabel += f' [{unit_labels["time"]}]' # Add the x and y labels ax1.set_xlabel(xlabel) ax1.set_ylabel(ylabel) # Should the y axis be logged? if log_yaxis in [True, 'log']: ax1.set_yscale('log') elif log_yaxis == 'symlog': ax1.set_yscale('symlog') # Plot the concentrations ax1.plot(self.t, yvals) # Add a size class legend, if requested if show_legend: # Construct the legend with(out) units legend = [f'{d:<1g}' for d in self.psd] if unit_labels is not None: legend = [f'{sc} {unit_labels["length"]}' for sc in legend] legend = np.array(legend) # Only show the requested size classes on the legend if size_classes_to_plot is not None: legend = legend[size_classes_to_plot] ax1.legend(legend) # Create and format the dissolution y axis if plot_dissolution: ax2 = ax1.twinx() # Construct the ylabel ylabel_diss = 'Dissolution mass concentration' if unit_labels is not None: ylabel_diss += f' [{unit_labels["mass_conc"]}]' ax2.set_ylabel(ylabel_diss) ax2.plot(self.t, np.sum(self.c_diss, axis=0), '--') # Always show the dissolution legend to make it clear # which line is dissolution ax2.legend(['Dissolution']) # Should the y axis be logged? if log_yaxis in [True, 'log']: ax2.set_yscale('log') elif log_yaxis == 'symlog': ax2.set_yscale('symlog') return fig, (ax1, ax2) else: return fig, ax1
def _construct_units(self, units): """ Construct strings for axis labels based on the units dictionary or string provided. Options given in `plot()` method. """ units_out = {} if (type(units) == dict and {'mass', 'volume', 'time', 'length'} <= units.keys()): # If we've been provided a dict of units, use these units_out = units.copy() elif type(units) == str and units.lower() == 'si': # Use SI units units_out['mass'] = 'kg' units_out['volume'] = 'm3' units_out['time'] = 's' units_out['length'] = 'm' elif type(units) == str and units.lower() == 'dim': # Use dimensional labels as the units units_out['mass'] = 'mass' units_out['volume'] = 'volume' units_out['time'] = 'time' units_out['length'] = 'length' elif units is None: # Nothing in, nothing out... return None else: raise ValueError('`units` parameter of `plot` function must be ' 'a dictionary with keys {`mass`, `volume`, `time` ' 'and `length`}, `SI` to denote use of SI ' 'units, or `dim` to denote use of dimensions.') # Construct the required compound units units_out['mass_conc'] = f'{units_out["mass"]}/{units_out["volume"]}' units_out['number_conc'] = f'/{units_out["volume"]}' units_out['rate'] = f'/{units_out["time"]}' # Return the unit strings return units_out