Source code for pastas.plotting.modelplots

"""This module contains plotting methods for Pastas Models."""

import logging

# Type Hinting
from typing import Dict, List, Optional, Union

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backends.backend_pdf import PdfPages
from matplotlib.ticker import LogFormatter, MultipleLocator
from pandas import Series, Timestamp, concat

from pastas.decorators import PastasDeprecationWarning, model_tmin_tmax
from pastas.plotting.plots import cum_frequency, diagnostics, pairplot, series
from pastas.plotting.plotutil import (
    _get_height_ratios,
    _get_stress_series,
    _table_formatter_params,
    _table_formatter_stderr,
)
from pastas.rfunc import HantushWellModel
from pastas.typing import Axes, Figure, Model, TimestampType

logger = logging.getLogger(__name__)


[docs]class Plotting: """Class that contains all plotting methods for Pastas models. Pastas models come with a number of predefined plotting methods to quickly visualize a Model. All of these methods are contained in the `plot` attribute of a model. For example, if we stored a :class:`pastas.model.Model` instance in the variable `ml`, the plot methods are available as follows:: >>> ml.plots.results() """
[docs] def __init__(self, ml: Model) -> None: self.ml = ml # Store a reference to the model class
def __repr__(self) -> str: msg = ( "This module contains all the built-in plotting options that are available." ) return msg
[docs] @model_tmin_tmax def plot( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, oseries: bool = True, simulation: bool = True, ax: Optional[Axes] = None, figsize: Optional[tuple] = None, legend: bool = True, **kwargs, ) -> Axes: """Make a plot of the observed and simulated series. Parameters ---------- tmin: str or pandas.Timestamp, optional tmax: str or pandas.Timestamp, optional oseries: bool, optional True to plot the observed time series. simulation: bool, optional True to plot the simulated time series. ax: matplotlib.axes.Axes, optional Axes to add the plot to. figsize: tuple, optional Tuple with the height and width of the figure in inches. legend: bool, optional Boolean to determine to show the legend (True) or not (False). Returns ------- ax: matplotlib.axes.Axes matplotlib axes with the simulated and optionally the observed time series. Examples -------- >>> ml.plot() """ if ax is None: _, ax = plt.subplots(figsize=figsize, **kwargs) if oseries: o = self.ml.observations(tmin=tmin, tmax=tmax) o_nu = self.ml.oseries.series.drop(o.index).loc[ o.index.min() : o.index.max() ] if not o_nu.empty: # plot parts of the oseries that are not used in grey o_nu.plot(linestyle="", marker=".", color="0.5", label="", ax=ax) o.plot(linestyle="", marker=".", color="k", ax=ax) if simulation: sim = self.ml.simulate(tmin=tmin, tmax=tmax) r2 = self.ml.stats.rsq(tmin=tmin, tmax=tmax) sim.plot(ax=ax, label=f"{sim.name} ($R^2$={r2:.2%})") # Dress up the plot # temporary fix, as set_xlim currently does not work with strings mpl=3.6.1 if tmin is not None: tmin = Timestamp(tmin) if tmax is not None: tmax = Timestamp(tmax) ax.set_xlim(tmin, tmax) ax.set_ylabel("Head") if legend: ax.legend(ncol=2, numpoints=3) plt.tight_layout() return ax
[docs] @model_tmin_tmax def results( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, figsize: tuple = (10, 8), split: bool = False, adjust_height: bool = True, return_warmup: bool = False, block_or_step: str = "step", stderr: bool = False, fig: Optional[Figure] = None, **kwargs, ) -> Axes: """Plot different results in one window to get a quick overview. Parameters ---------- tmin: str or pandas.Timestamp, optional tmax: str or pandas.Timestamp, optional figsize: tuple, optional tuple of size 2 to determine the figure size in inches. split: bool, optional Split the stresses in multiple stresses when possible. Default is False. adjust_height: bool, optional Adjust the height of the graphs, so that the vertical scale of all the subplots on the left is equal. Default is True. return_warmup: bool, optional Show the warmup-period. Default is false. block_or_step: str, optional Plot the block- or step-response on the right. Default is 'step'. stderr : bool, optional If True the standard error of the parameter values are shown. Please be aware of the conditions for reliable uncertainty estimates, more information here: https://pastas.readthedocs.io/en/master/examples/diagnostic_checking.html fig: matplotib.Figure instance, optional Optionally provide a matplotib.Figure instance to plot onto. **kwargs: dict, optional Optional arguments, passed on to the matplotlib.pyplot.figure method. Returns ------- list of matplotlib.axes.Axes Examples -------- >>> ml.plots.results() """ # Number of rows to make the figure with o = self.ml.observations(tmin=tmin, tmax=tmax) o_nu = self.ml.oseries.series.drop(o.index) if return_warmup: o_nu = o_nu[tmin - self.ml.settings["warmup"] : tmax] else: o_nu = o_nu[tmin:tmax] sim = self.ml.simulate(tmin=tmin, tmax=tmax, return_warmup=return_warmup) res = self.ml.residuals(tmin=tmin, tmax=tmax) contribs = self.ml.get_contributions( split=split, tmin=tmin, tmax=tmax, return_warmup=return_warmup ) ylims = [ ( min([sim.min(), o[tmin:tmax].min()]), max([sim.max(), o[tmin:tmax].max()]), ), (res.min(), res.max()), ] # residuals are bigger than noise if adjust_height: for contrib in contribs: hs = contrib.loc[tmin:tmax] if hs.empty: if contrib.empty: ylims.append((0.0, 0.0)) else: ylims.append((contrib.min(), hs.max())) else: ylims.append((hs.min(), hs.max())) hrs = _get_height_ratios(ylims) else: hrs = [2] + [1] * (len(contribs) + 1) # Make main Figure if fig is None: fig = plt.figure(figsize=figsize, **kwargs) gs = fig.add_gridspec( ncols=2, nrows=len(contribs) + 2, width_ratios=[2, 1], height_ratios=hrs ) # Main frame ax1 = fig.add_subplot(gs[0, 0]) o.plot(ax=ax1, linestyle="", marker=".", color="k", x_compat=True) if not o_nu.empty: # plot parts of the oseries that are not used in grey o_nu.plot( ax=ax1, linestyle="", marker=".", color="0.5", label="", x_compat=True, zorder=-1, ) # add rsq to simulation r2 = self.ml.stats.rsq(tmin=tmin, tmax=tmax) sim.plot(ax=ax1, x_compat=True, label=f"{sim.name} ($R^2$={r2:.2%})") ax1.legend(loc=(0, 1), ncol=3, frameon=False, numpoints=3) ax1.set_ylim(ylims[0]) ax1.set_ylabel("Head") # Residuals and noise ax2 = fig.add_subplot(gs[1, 0], sharex=ax1) res.plot(ax=ax2, color="k", x_compat=True) if self.ml.settings["noise"] and self.ml.noisemodel: noise = self.ml.noise(tmin=tmin, tmax=tmax) noise.plot(ax=ax2, x_compat=True) ax2.axhline(0.0, color="k", linestyle="--", zorder=0) ax2.legend(loc=(0, 1), ncol=3, frameon=False) # Add a row for each stressmodel rmin = 0 # tmin of the response rmax = 0 # tmax of the response axb = None i = 0 for sm_name, sm in self.ml.stressmodels.items(): # plot the contribution nsplit = sm.get_nsplit() if split and nsplit > 1: for istress in range(nsplit): ax = fig.add_subplot(gs[i + 2, 0], sharex=ax1) contribs[i].plot(ax=ax, x_compat=True) ax.legend(loc=(0, 1), ncol=3, frameon=False) ax.set_ylabel("Rise") if adjust_height: ax.set_ylim(ylims[i + 2]) i = i + 1 # plot the response axb, rmin, rmax = self._plot_response_in_results( sm_name, block_or_step, rmin, rmax, axb, gs, i, istress=istress ) else: ax = fig.add_subplot(gs[i + 2, 0], sharex=ax1) contribs[i].plot(ax=ax, x_compat=True) title = [stress.name for stress in sm.stress] if len(title) > 3: title = title[:3] + ["..."] ax.set_title( f"Stresses: {title}", loc="right", fontsize=plt.rcParams["legend.fontsize"], ) ax.legend(loc=(0, 1), ncol=3, frameon=False) ax.set_ylabel("Rise") if adjust_height: ax.set_ylim(ylims[i + 2]) i = i + 1 # plot the response axb, rmin, rmax = self._plot_response_in_results( sm_name, block_or_step, rmin, rmax, axb, gs, i ) if axb is not None: axb.set_xlim(rmin, rmax) # xlim sets minorticks back after plots: ax1.minorticks_off() # temporary fix, as set_xlim currently does not work with strings mpl=3.6.1 if tmin is not None: tmin = Timestamp(tmin) if tmax is not None: tmax = Timestamp(tmax) if return_warmup: ax1.set_xlim(tmin - self.ml.settings["warmup"], tmax) else: ax1.set_xlim(tmin, tmax) # sometimes, ticks suddenly appear on top plot, turn off just in case plt.setp(ax1.get_xticklabels(), visible=False) for ax in fig.axes: ax.grid(True) if isinstance(fig, plt.Figure): fig.tight_layout(pad=0.0) # Before making the table # Draw parameters table ax3 = fig.add_subplot(gs[0:2, 1]) n_free = self.ml.parameters.vary.sum() ax3.set_title( f"Model Parameters ($n_c$={n_free})", loc="left", fontsize=plt.rcParams["legend.fontsize"], ) p = self.ml.parameters.loc[:, ["name"]].copy() p.loc[:, "name"] = p.index p.loc[:, "optimal"] = self.ml.parameters.loc[:, "optimal"].apply( _table_formatter_params ) if stderr: stderr = ( self.ml.parameters.loc[:, "stderr"] / self.ml.parameters.loc[:, "optimal"] ) p.loc[:, "stderr"] = stderr.abs().apply(_table_formatter_stderr) ax3.axis("off") ax3.table( bbox=(0.0, 0.0, 1.0, 1.0), cellText=p.values, colWidths=[0.5, 0.25, 0.25], colLabels=p.columns, ) return fig.axes
def _plot_response_in_results( self, sm_name, block_or_step, rmin, rmax, axb, gs, i, istress=None ): """Internal method to plot the response of a Stressmodel in the results-plot""" rkwargs = {} if self.ml.stressmodels[sm_name].rfunc is not None: if isinstance(self.ml.stressmodels[sm_name].rfunc, HantushWellModel): rkwargs = {"warn": False} if istress is None: # show the response of the first well, which gives more information than istress = None istress = 0 response = self.ml._get_response( block_or_step=block_or_step, name=sm_name, add_0=True, istress=istress, **rkwargs, ) if response is not None: rmax = max(rmax, response.index.max()) axb = gs.figure.add_subplot(gs[i + 1, 1], sharex=axb) response.plot(ax=axb) if block_or_step == "block": title = "Block response" rmin = response.index[1] axb.set_xscale("log") axb.xaxis.set_major_formatter(LogFormatter()) else: title = "Step response" axb.set_title(title, fontsize=plt.rcParams["legend.fontsize"]) return axb, rmin, rmax
[docs] @model_tmin_tmax def decomposition( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, ytick_base: bool = True, split: bool = True, figsize: tuple = (10, 8), axes: Optional[Axes] = None, name: Optional[str] = None, return_warmup: bool = False, min_ylim_diff: Optional[float] = None, **kwargs, ) -> Axes: """Plot the decomposition of a time-series in the different stresses. Parameters ---------- tmin: str or pandas.Timestamp, optional tmax: str or pandas.Timestamp, optional ytick_base: Boolean or float, optional Make the ytick-base constant if True, set this base to float if a float. split: bool, optional Split the stresses in multiple stresses when possible. Default is True. axes: matplotlib.axes.Axes instance, optional Matplotlib Axes instance to plot the figure on to. figsize: tuple, optional tuple of size 2 to determine the figure size in inches. name: str, optional Name to give the simulated time series in the legend. return_warmup: bool, optional Show the warmup-period. Default is false. min_ylim_diff: float, optional Float with the difference in the ylimits. Default is None **kwargs: dict, optional Optional arguments, passed on to the matplotlib.pyplot.subplots method. Returns ------- axes: list of matplotlib.axes.Axes """ o = self.ml.observations(tmin=tmin, tmax=tmax) # determine the simulation sim = self.ml.simulate(tmin=tmin, tmax=tmax, return_warmup=return_warmup) if name is not None: sim.name = name # determine the influence of the different stresses contribs = self.ml.get_contributions( split=split, tmin=tmin, tmax=tmax, return_warmup=return_warmup ) names = [s.name for s in contribs] if self.ml.transform: contrib = self.ml.get_transform_contribution(tmin=tmin, tmax=tmax) contribs.append(contrib) names.append(self.ml.transform.name) # determine ylim for every graph, to scale the height ylims = [ (min([sim.min(), o[tmin:tmax].min()]), max([sim.max(), o[tmin:tmax].max()])) ] for contrib in contribs: hs = contrib[tmin:tmax] if hs.empty: if contrib.empty: ylims.append((0.0, 0.0)) else: ylims.append((contrib.min(), hs.max())) else: ylims.append((hs.min(), hs.max())) if min_ylim_diff is not None: for i, ylim in enumerate(ylims): if np.diff(ylim) < min_ylim_diff: ylims[i] = ( np.mean(ylim) - min_ylim_diff / 2, np.mean(ylim) + min_ylim_diff / 2, ) # determine height ratios height_ratios = _get_height_ratios(ylims) nrows = len(contribs) + 1 if axes is None: # open a new figure gridspec_kw = {"height_ratios": height_ratios} fig, axes = plt.subplots( nrows, sharex=True, figsize=figsize, gridspec_kw=gridspec_kw, **kwargs ) axes = np.atleast_1d(axes) o_label = o.name set_axes_properties = True else: if len(axes) != nrows: msg = "Makes sure the number of axes equals the number of series" raise Exception(msg) fig = axes[0].figure o_label = "" set_axes_properties = False # plot simulation and observations in top graph o_nu = self.ml.oseries.series.drop(o.index) if not o_nu.empty: # plot parts of the oseries that are not used in grey o_nu.plot( linestyle="", marker=".", color="0.5", label="", markersize=2, ax=axes[0], x_compat=True, ) o.plot( linestyle="", marker=".", color="k", label=o_label, markersize=3, ax=axes[0], x_compat=True, ) r2 = self.ml.stats.rsq(tmin=tmin, tmax=tmax) sim.plot(ax=axes[0], x_compat=True, label=f"{sim.name} ($R^2$={r2:.2%})") if set_axes_properties: axes[0].set_ylim(ylims[0]) axes[0].grid(True) axes[0].legend(ncol=3, frameon=False, numpoints=3) axes[0].set_ylabel("Head") if ytick_base and set_axes_properties: if isinstance(ytick_base, bool): # determine the ytick-spacing of the top graph yticks = axes[0].yaxis.get_ticklocs() if len(yticks) > 1: ytick_base = yticks[1] - yticks[0] else: ytick_base = None axes[0].yaxis.set_major_locator(MultipleLocator(base=ytick_base)) # plot the influence of the stresses for i, contrib in enumerate(contribs): ax = axes[i + 1] contrib.plot(ax=ax, x_compat=True) if set_axes_properties: if ytick_base: # set the ytick-spacing equal to the top graph locator = MultipleLocator(base=ytick_base) ax.yaxis.set_major_locator(locator) ax.set_title(names[i]) ax.set_ylim(ylims[i + 1]) ax.grid(True) ax.minorticks_off() ax.set_ylabel("Rise") if set_axes_properties: # temporary fix, as set_xlim currently does not work with strings mpl=3.6.1 if tmin is not None: tmin = Timestamp(tmin) if tmax is not None: tmax = Timestamp(tmax) axes[0].set_xlim(tmin, tmax) fig.tight_layout(pad=0.0) return axes
[docs] @model_tmin_tmax def diagnostics( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, figsize: tuple = (10, 5), bins: int = 50, acf_options: Optional[dict] = None, fig: Optional[Figure] = None, alpha: float = 0.05, **kwargs, ) -> Axes: """Plot a window that helps in diagnosing basic model assumptions. Parameters ---------- tmin: str or pandas.Timestamp, optional start time for which to calculate the residuals. tmax: str or pandas.Timestamp, optional end time for which to calculate the residuals. figsize: tuple, optional Tuple with the height and width of the figure in inches. bins: int optional number of bins used for the histogram. 50 is default. acf_options: dict, optional dictionary with keyword arguments that are passed on to pastas.stats.acf. fig: matplotlib.pyplot.Figure, optional Optionally provide a matplotlib.pyplot.Figure instance to plot onto. alpha: float, optional Significance level to calculate the (1-alpha)-confidence intervals. **kwargs: dict, optional Optional keyword arguments, passed on to matplotlib.pyplot.figure method. Returns ------- axes: list of matplotlib.axes.Axes Examples -------- >>> axes = ml.plots.diagnostics() Notes ----- This plot assumed that the noise or residuals follow a Normal distribution. See Also -------- pastas.stats.acf Method that computes the autocorrelation. scipy.stats.probplot Method use to plot the probability plot. """ if self.ml.settings["noise"]: res = self.ml.noise(tmin=tmin, tmax=tmax).iloc[1:] else: res = self.ml.residuals(tmin=tmin, tmax=tmax) sim = self.ml.simulate(tmin=tmin, tmax=tmax) if self.ml.interpolate_simulation: sim_interpolated = np.interp(res.index.asi8, sim.index.asi8, sim.values) sim = Series(index=res.index, data=sim_interpolated) return diagnostics( series=res, sim=sim, figsize=figsize, bins=bins, fig=fig, acf_options=acf_options, alpha=alpha, **kwargs, )
[docs] @model_tmin_tmax def cum_frequency( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, ax: Optional[Axes] = None, figsize: tuple = (5, 2), **kwargs, ) -> Axes: """Plot the cumulative frequency for the observations and simulation. Parameters ---------- Parameters ---------- tmin: str or pandas.Timestamp, optional tmax: str or pandas.Timestamp, optional ax: matplotlib.axes.Axes, optional Axes to add the plot to. figsize: tuple, optional Tuple with the height and width of the figure in inches. **kwargs: Passed on to plot_cum_frequency. Returns ------- ax: matplotlib.axes.Axes See Also -------- ps.stats.plot_cum_frequency """ sim = self.ml.simulate(tmin=tmin, tmax=tmax) obs = self.ml.observations(tmin=tmin, tmax=tmax) return cum_frequency(obs, sim, ax=ax, figsize=figsize, **kwargs)
[docs] def block_response( self, stressmodels: Optional[List[str]] = None, ax: Optional[Axes] = None, figsize: Optional[tuple] = None, legend: bool = True, **kwargs, ) -> Axes: """Plot the block response for a specific stressmodels. Parameters ---------- stressmodels: list, optional List with the stressmodels to plot the block response for. ax: matplotlib.axes.Axes, optional Axes to add the plot to. figsize: tuple, optional Tuple with the height and width of the figure in inches. legend: bool, optional Boolean to determine to show the legend. Default is True. Returns ------- matplotlib.axes.Axes matplotlib axes instance. """ if ax is None: _, ax = plt.subplots(figsize=figsize, **kwargs) if not stressmodels: stressmodels = self.ml.stressmodels.keys() legend = [] for name in stressmodels: if hasattr(self.ml.stressmodels[name], "rfunc"): self.ml.get_block_response(name).plot(ax=ax) legend.append(name) else: logger.warning("Stressmodel %s not in stressmodels list.", name) ax.set_xlim(0) ax.set_xlabel("Time [days]") if legend: ax.legend(legend) return ax
[docs] def step_response( self, stressmodels: Optional[List[str]] = None, ax: Optional[Axes] = None, figsize: Optional[tuple] = None, legend: bool = True, **kwargs, ) -> Axes: """Plot the step response for a specific stressmodels. Parameters ---------- stressmodels: list, optional List with the stressmodels to plot the block response for. ax: matplotlib.axes.Axes, optional Axes to add the plot to. figsize: tuple, optional Tuple with the height and width of the figure in inches. legend: bool, optional Boolean to determine to show the legend. Default is True. Returns ------- matplotlib.axes.Axes matplotlib axes instance. """ if ax is None: _, ax = plt.subplots(figsize=figsize, **kwargs) if not stressmodels: stressmodels = self.ml.stressmodels.keys() legend = [] for name in stressmodels: if hasattr(self.ml.stressmodels[name], "rfunc"): self.ml.get_step_response(name).plot(ax=ax) legend.append(name) else: logger.warning("Stressmodel %s not in stressmodels list.", name) ax.set_xlim(0) ax.set_xlabel("Time [days]") if legend: ax.legend(legend) return ax
[docs] @model_tmin_tmax def stresses( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, cols: int = 1, split: bool = True, sharex: bool = True, figsize: tuple = (10, 8), **kwargs, ) -> Axes: """This method creates a graph with all the stresses used in the model. Parameters ---------- tmin: str or pd.Timestamp, optional tmax: str or pd.Timestamp, optional cols: int number of columns used for plotting. split: bool, optional Split the stress sharex: bool, optional Sharex the x-axis. figsize: tuple, optional Tuple with the height and width of the figure in inches. Returns ------- axes: matplotlib.axes.Axes matplotlib axes instance. """ stresses = _get_stress_series(self.ml, split=split) rows = len(stresses) rows = -(-rows // cols) # round up without additional import fig, axes = plt.subplots(rows, cols, sharex=sharex, figsize=figsize, **kwargs) if hasattr(axes, "flatten"): axes = axes.flatten() else: axes = [axes] for ax, stress in zip(axes, stresses): stress.plot(ax=ax) ax.legend([stress.name], loc=2) plt.xlim(tmin, tmax) fig.tight_layout(pad=0.0) return axes
@PastasDeprecationWarning( remove_version="1.6.0", reason=( "Quantifying contributions in one plot is ambiguous. " "Users are encouraged develop this themselves." ), ) @model_tmin_tmax def contributions_pie( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, ax: Optional[Axes] = None, figsize: Optional[Figure] = None, split: bool = True, partition: str = "std", wedgeprops: Optional[dict] = None, startangle: float = 90.0, autopct: str = "%1.1f%%", **kwargs, ) -> Axes: """Make a pie chart of the contributions. This plot is based on the TNO Groundwatertoolbox. Parameters ---------- tmin: str or pandas.Timestamp, optional. tmax: str or pandas.Timestamp, optional. ax: matplotlib.axes.Axes, optional The Axes to plot the pie chart on. A new figure and axes will be created of not provided. figsize: tuple, optional tuple of size 2 to determine the figure size in inches. split: bool, optional Split the stresses in multiple stresses when possible. partition : str statistic to use to determine contribution of stress, either 'sum' or 'std' (default). wedgeprops: dict, optional, default None dict containing pie chart wedge properties, default is None, which sets edgecolor to white. startangle: float at which angle to start drawing wedges. autopct: str format string to add percentages to pie chart. kwargs: dict, optional The keyword arguments are passed on to plt.pie. Returns ------- ax: matplotlib.axes.Axes """ if ax is None: _, ax = plt.subplots(figsize=figsize) contribs = self.ml.get_contributions(split=split, tmin=tmin, tmax=tmax) if partition == "sum": # the part of each pie is determined by the sum of the contribution frac = [np.abs(contrib).sum() for contrib in contribs] elif partition == "std": # the part of each pie is determined by the std of the contribution frac = [contrib.std() for contrib in contribs] else: msg = "Unknown value for partition: {}".format(partition) raise (Exception(msg)) # make sure the unexplained part is 100 - evp % evp = self.ml.stats.evp(tmin=tmin, tmax=tmax) / 100 frac = np.array(frac) / sum(frac) * evp frac = np.append(frac, 1 - evp) if "labels" not in kwargs: labels = [contrib.name for contrib in contribs] labels.append("Unexplained") kwargs["labels"] = labels if wedgeprops is None: wedgeprops = {"edgecolor": "w"} ax.pie( frac, wedgeprops=wedgeprops, startangle=startangle, autopct=autopct, **kwargs, ) ax.axis("equal") return ax
[docs] @model_tmin_tmax def stacked_results( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, figsize: tuple = (10, 8), stackcolors: Optional[Union[Dict, List]] = None, stacklegend: bool = False, stacklegend_kws: Optional[dict] = None, **kwargs, ) -> Axes: """Create a results plot, similar to `ml.plots.results()`, in which the individual contributions of stresses (in stressmodels with multiple stresses) are stacked. Parameters ---------- tmin : str or pandas.Timestamp, optional tmax : str or pandas.Timestamp, optional figsize : tuple, optional stackcolors : dict or list, optional Either dictionary with stress names as keys and colors as values, or a list of colors. By default None which applies colors according to the order of stresses in the StressModel. Passing a dictionary can be useful to apply the same color to each stress across multiple figures. stacklegend : bool, optional Add legend to the stacked plot. stacklegend_kws : dict, optional dict with keyword arguments for stackplot legend Returns ------- axes: list of axes objects """ # Contribution per stress on model results plot def custom_sort(t): """Sort by mean contribution.""" return t[1].mean() # Create standard results plot axes = self.ml.plots.results(tmin=tmin, tmax=tmax, figsize=figsize, **kwargs) nsm = len(self.ml.stressmodels) # loop over axes showing stressmodel contributions for i, sm in zip(range(3, 3 + 2 * nsm, 2), self.ml.stressmodels.keys()): # Get the contributions for StressModels with multiple stresses contributions = [] sml = self.ml.stressmodels[sm] if (len(sml.stress) > 0) and (sml._name == "WellModel"): if stackcolors is None: stackcolors = { wnam: f"C{iw+1}" for iw, wnam in enumerate(sml.distances.index) } elif isinstance(stackcolors, list): stackcolors = { name: icolor for name, icolor in zip(sml.distances.index, stackcolors) } elif not isinstance(stackcolors, dict): raise TypeError("stackcolors must be None, list, or dict.") nsplit = sml.get_nsplit() ax_step = axes[i] # step response axis ax_step.lines[0].remove() # remove step response for r=1 m if nsplit > 1: for istress in range(len(sml.stress)): h = self.ml.get_contribution( sm, istress=istress, tmin=tmin, tmax=tmax ) name = sml.stress[istress].name if name is None: name = sm contributions.append((name, h)) # plot step responses for each well, scaled with distance p = sml.get_parameters(model=self.ml, istress=istress) step = self.ml.get_step_response(sm, p=p) ax_step.plot(step.index, step, c=stackcolors[name], label=name) # recalculate y-limits step response axes ax_step.relim() else: h = self.ml.get_contribution(sm, tmin=tmin, tmax=tmax) name = sm contributions.append((name, h)) contributions.sort(key=custom_sort) # add stacked plot to correct axes ax = axes[i - 1] ax.lines[0].remove() # delete existing line names = [c[0] for c in contributions] # get names contrib = [c[1] for c in contributions] # get time series vstack = concat(contrib, axis=1, sort=False) colors = [stackcolors[name] for name in names] ax.stackplot(vstack.index, vstack.values.T, colors=colors, labels=names) if stacklegend: if stacklegend_kws is None: stacklegend_kws = {} ncol = stacklegend_kws.pop("ncol", 5) fontsize = stacklegend_kws.pop("fontsize", 6) loc = stacklegend_kws.pop("loc", "best") ax.legend(loc=loc, ncol=ncol, fontsize=fontsize, **stacklegend_kws) # y-scale does not show 0 ylower, yupper = ax.get_ylim() if (ylower < 0) and (yupper < 0): ax.set_ylim(top=0) elif (ylower > 0) and (yupper > 0): ax.set_ylim(bottom=0) return axes
[docs] @model_tmin_tmax def series( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, split: bool = True, **kwargs, ) -> Axes: """Method to plot all the time series going into a Pastas Model. Parameters ---------- tmin: str or pd.Timestamp tmax: str or pd.Timestamp split: bool, optional Split the stresses in multiple stresses when possible. hist: bool Histogram for the Series. Returns the number of observations, mean, skew and kurtosis as well. For the head series the result of the shapiro-wilk test (p > 0.05) for normality is reported. bins: float Number of bins in the histogram plot. titles: bool Set the titles or not. Taken from the name attribute of the Series. labels: List of str List with the labels for each subplot. figsize: tuple Set the size of the figure. Returns ------- matplotlib.axes.Axes """ obs = self.ml.observations(tmin=tmin, tmax=tmax) stresses = _get_stress_series(self.ml, split=split) axes = series(obs, stresses=stresses, **kwargs) return axes
[docs] @model_tmin_tmax def summary( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, results_kwargs: Optional[dict] = None, diagnostics_kwargs: Optional[dict] = None, ) -> Figure: """Create a plot with the results and diagnostics plot. Parameters ---------- tmin: str or pd.Timestamp, optional tmax: str or pd.Timestamp, optional fname: str, optional string with the file name / path to store the PDF file. dpi: int, optional dpi to save the figure with. results_kwargs: dict, optional dictionary passed on to ml.plots.results method. diagnostics_kwargs: dict, optional dictionary passed on to ml.plots.diagnostics method. Returns ------- fig: matplotlib.pyplot.Figure instance """ if results_kwargs is None: results_kwargs = {} if diagnostics_kwargs is None: diagnostics_kwargs = {} fig = plt.figure(figsize=(8.27, 11.69), dpi=50) fig1, fig2 = fig.subfigures(2, 1, height_ratios=[1.25, 1.0]) self.results(fig=fig1, tmin=tmin, tmax=tmax, **results_kwargs) self.diagnostics(fig=fig2, tmin=tmin, tmax=tmax, **diagnostics_kwargs) fig2.subplots_adjust(wspace=0.2) fig1.suptitle("Model Results", fontweight="bold") fig2.suptitle("Model Diagnostics", fontweight="bold") plt.subplots_adjust(left=0.1, top=0.9, right=0.95, bottom=0.1) return fig
[docs] @model_tmin_tmax def summary_pdf( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, results_kwargs: Optional[dict] = None, diagnostics_kwargs: Optional[dict] = None, fname: Optional[str] = None, dpi: int = 150, ) -> Figure: """Create a PDF file (A4) with the results and diagnostics plot. Parameters ---------- tmin: str or pd.Timestamp, optional tmax: str or pd.Timestamp, optional results_kwargs: dict, optional dictionary passed on to ml.plots.results method. diagnostics_kwargs: dict, optional dictionary passed on to ml.plots.diagnostics method. fname: str, optional string with the file name / path to store the PDF file. dpi: int, optional dpi to save the figure with. Returns ------- fig: matplotlib.pyplot.Figure instance """ fname = "{}.pdf".format(self.ml.name) if fname is None else fname pdf = PdfPages(fname) fig = self.summary( tmin=tmin, tmax=tmax, results_kwargs=results_kwargs, diagnostics_kwargs=diagnostics_kwargs, ) pdf.savefig(fig, orientation="portrait", dpi=dpi) pdf.close() return fig
[docs] @model_tmin_tmax def pairplot( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, bins: Optional[int] = None, split: bool = True, ) -> Dict[str, Axes]: """Method to plot all the time series going into a Pastas Model. Parameters ---------- tmin: str or pd.Timestamp tmax: str or pd.Timestamp bins : Optional[int], optional Number of bins in the histogram, by default None which uses Sturge's Rule to determine the number bins split: bool, optional Split the stresses in multiple stresses when possible. Returns ------- matplotlib.axes.Axes """ obs = self.ml.observations(tmin=tmin, tmax=tmax) stresses = _get_stress_series(self.ml, split=split) series = [obs] + list(stresses) axd = pairplot(data=series, bins=bins) return axd
[docs] @model_tmin_tmax def contribution( self, tmin: Optional[TimestampType] = None, tmax: Optional[TimestampType] = None, name: Optional[str] = None, plot_stress: bool = True, plot_response: bool = False, block_or_step: str = "step", istress: Optional[int] = None, ax: Optional[plt.Axes] = None, **kwargs, ): """Plot the contribution of a stressmodel and optionally the stress and the response. Parameters ---------- tmin: str or pd.Timestamp, optional tmax: str or pd.Timestamp, optional name: str, optional Name of the stressmodel to plot the contribution for. plot_stress: bool, optional Plot the stress on an overlay axes. plot_response: bool, optional Plot the step response on a separate axes on the right. block_or_step: str, optional Type of response to plot, either 'block' or 'step'. Default is 'step'. istress: int, optional Index of the stress to plot the response for. Default is None. ax: dict or matplotlib.axes.Axes, optional Dictionary containing axes with 'con' and 'rf' as keys, or a single axes instance for the contribution plot. kwargs: dict, optional Passed to the stress plot. Returns ------- axes: dict Dictionary containing the axes for the contribution, and optionally the stress and response. """ if name is None: raise ValueError( "Please provide a name for the stressmodel: " f"{list(self.ml.stressmodels.keys())}" ) c = self.ml.get_contribution(name, tmin=tmin, tmax=tmax, istress=istress) if ax is None: if plot_response: _, axes = plt.subplot_mosaic( [["con", "con", "con", "con", "rf"]], constrained_layout=True, figsize=(10, 2), ) else: _, axes = plt.subplot_mosaic( [["con"]], constrained_layout=True, figsize=(10, 2), ) else: if not isinstance(ax, dict): axes = {"con": ax} axes["con"].plot(c.index, c, label=f"contribution {c.name}") if plot_stress: sm = self.ml.stressmodels[name] # get stress if sm._name == "RechargeModel": # compute recharge s = sm.get_stress(tmin=tmin, tmax=tmax, istress=istress) stress_name = s.name else: s = self.ml.get_stress(name, tmin=tmin, tmax=tmax, istress=istress) # if multiple stresses, sum stresses together if isinstance(s, list): s = concat(s, axis=1).sum(axis=1, fill_value=0.0) stress_name = name else: stress_name = s.name # use up to flip stress if necessary up = 1.0 if sm.rfunc.up in [True, None] else -1.0 # add second axes for stress axes["stress"] = axes["con"].twinx() if "c" not in kwargs: color = kwargs.pop("color", (0.4, 0.4, 0.4)) axes["stress"].plot( s.index, up * s, color=color, lw=1.0, label="stress", **kwargs, ) axes["stress"].set_ylabel(f"stress '{stress_name}'") # flip order of stress and contributions axes (contributions on top) axes["con"].patch.set_visible(False) axes["stress"].patch.set_visible(True) axes["con"].set_zorder(axes["stress"].get_zorder() + 1) # add both lines to legend h1, l1 = axes["con"].get_legend_handles_labels() h2, l2 = axes["stress"].get_legend_handles_labels() axes["con"].legend( h1 + h2, l1 + l2, loc=(0, 1), frameon=False, ncol=2, fontsize="small" ) else: axes["con"].legend(loc=(0, 1), frameon=False, ncol=1, fontsize="small") if plot_response: if "rf" not in axes: raise ValueError( "No axes defined for response. " "Provide a dictionary containing axes with 'con' and 'rf' as keys." ) if block_or_step == "step": self.step_response(stressmodels=[name], ax=axes["rf"], legend=False) else: self.block_response(stressmodels=[name], ax=axes["rf"], legend=False) axes["rf"].yaxis.set_label_position("right") axes["rf"].yaxis.tick_right() h3, _ = axes["rf"].get_legend_handles_labels() if len(h3) == 1: axes["rf"].legend( h3, [f"{block_or_step} response"], loc=(0, 1), frameon=False, fontsize="small", ) axes["rf"].grid(True) axes["con"].grid(True) axes["con"].set_ylabel("rise") return axes