Source code for pastas.plotting.plotly

"""This module contains interactive plots for Pastas models."""

import numpy as np
import pandas as pd
import plotly.graph_objs as go
from plotly.subplots import make_subplots
from scipy.stats import norm, probplot

from pastas.extensions import register_model_accessor
from pastas.plotting.plotutil import (
    _get_height_ratios,
    _table_formatter_params,
    _table_formatter_stderr,
)
from pastas.rfunc import HantushWellModel
from pastas.stats import acf


[docs]@register_model_accessor("plotly") class Plotly: """Extension class for interactive plotly figures for pastas Models. Usage ----- >>> ps.extensions.register_plotly_extension() INFO: Registered plotly plotting methods in Model class, e.g. `ml.plotly.plot()`. >>> fig = ml.plotly.results() >>> fig.write_html("results_figure.html") Methods ------- plot plot oseries and model simulation, interactive version of `ml.plot()` results plot oseries, model simulation, contribution, step responses and parameters table,interactive version of `ml.plots.results()` diagnostics plot noise, autocorrelation, distribution of noise and heteroscedasticity, interactive version of `ml.plots.diagnostics()` """
[docs] def __init__(self, model): self._model = model
[docs] def plot(self, tmin=None, tmax=None): """Plotly version of pastas.Model.plot(). Parameters ---------- tmin : pd.Timestamp, optional start time for model simulation, by default None tmax : pd.Timestamp, optional end time for model simulation, by default None Returns ------- fig : plotly.Figure plotly Figure showing oseries and model simulation """ traces = [] o = self._model.observations() o_nu = self._model.oseries.series.drop(o.index) # add oseries if not o_nu.empty: trace_oseries_nu = go.Scattergl( x=o_nu.index, y=o_nu.values, mode="markers", marker={"color": "gray", "size": 3}, name="(unused)", legendgroup="oseries", showlegend=False, ) trace_oseries = go.Scattergl( x=o.index, y=o.values, mode="markers", marker={"color": "black", "size": 5}, name=self._model.oseries.name, legendgroup="oseries", ) traces.append(trace_oseries_nu) traces.append(trace_oseries) else: trace_oseries = go.Scattergl( x=o.index, y=o.values, mode="markers", marker_color="black", name=self._model.oseries.name, legendgroup="oseries", ) traces.append(trace_oseries) sim = self._model.simulate(tmin=tmin, tmax=tmax) trace_sim = go.Scattergl( x=sim.index, y=sim.values, mode="lines", marker_color="#1F77B4", name=f"Sim (R<sup>2</sup> = {self._model.stats.rsq():.3f})", legendgroup="sim", ) traces.append(trace_sim) layout = { "xaxis": {"range": [sim.index[0], sim.index[-1]]}, "yaxis": {"title": "(m NAP)"}, "legend": { "traceorder": "reversed+grouped", "orientation": "h", "xanchor": "left", "yanchor": "bottom", "x": 0.0, "y": 1.02, }, "dragmode": "pan", "margin": dict(t=70, b=40, l=40, r=10), } return go.Figure(data=traces, layout=go.Layout(layout))
[docs] def results(self, tmin=None, tmax=None): """Plotly version of pastas.Model.plots.results(). Parameters ---------- ml : pastas.Model model to plot results for tmin : pd.Timestamp, optional start time for model results, by default None tmax : pd.Timestamp, optional end time for model results, by default None Returns ------- dict dictionary containing plotly subplots data and layout """ if tmin is None: tmin = self._model.settings["tmin"] if tmax is None: tmax = self._model.settings["tmax"] # for collecting data traces = [] # dimensions nsm = len(self._model.stressmodels) nrows = 2 + nsm naxes = 2 + 2 * nsm # oseries o = self._model.observations(tmin=tmin, tmax=tmax) o_nu = self._model.oseries.series.drop(o.index) trace_oseries_nu = go.Scattergl( x=o_nu.index, y=o_nu.values, mode="markers", marker={"color": "gray", "size": 3}, name="(unused)", legendgroup="0", showlegend=False, xaxis="x", yaxis="y", ) traces.append(trace_oseries_nu) trace_oseries = go.Scattergl( x=o.index, y=o.values, mode="markers", marker={"color": "black", "size": 5}, name=self._model.oseries.name, legendgroup="1", xaxis="x", yaxis="y", ) traces.append(trace_oseries) # simulation sim = self._model.simulate(tmin=tmin, tmax=tmax) trace_sim = go.Scattergl( x=sim.index, y=sim.values, mode="lines", marker_color="#1F77B4", name=f"simulation (R<sup>2</sup> = {self._model.stats.rsq():.3f})", legendgroup="2", xaxis="x", yaxis="y", ) traces.append(trace_sim) # residuals res = self._model.residuals(tmin=tmin, tmax=tmax) trace_res = go.Scattergl( x=res.index, y=res.values, mode="lines", marker_color="black", name="residuals", # legendgroup="residuals", xaxis="x", yaxis="y2", showlegend=False, ) traces.append(trace_res) # noise if self._model.settings["noise"]: noise = self._model.noise(tmin=tmin, tmax=tmax) trace_noise = go.Scattergl( x=noise.index, y=noise.values, mode="lines", marker_color="#1F77B4", name="noise", # legendgroup="residuals", xaxis="x", yaxis="y2", showlegend=False, ) traces.append(trace_noise) # contributions contribs = self._model.get_contributions( split=False, tmin=tmin, tmax=tmax, # return_warmup=return_warmup, ) for i, c in enumerate(contribs): iax_contrib = 3 + i iax_response = 3 + nsm + i trace_c = go.Scattergl( x=c.index, y=c.values, mode="lines", marker_color="#1F77B4", name=f"{c.name}", legendgroup=f"contrib{i}", xaxis="x", yaxis=f"y{iax_contrib}", showlegend=False, ) traces.append(trace_c) # response rkwargs = {} p = None if self._model.stressmodels[c.name].rfunc is not None: if isinstance(self._model.stressmodels[c.name].rfunc, HantushWellModel): rkwargs = {"warn": False} p = self._model.stressmodels[c.name].get_parameters( model=self._model, istress=0 ) response = self._model._get_response( block_or_step="step", name=c.name, p=p, add_0=True, **rkwargs ) trace_r = go.Scatter( x=response.index, y=response.values, mode="lines", marker_color="#1F77B4", name=f"{c.name}", # legendgroup=f"contrib{i}", xaxis=f"x{3+nsm}", yaxis=f"y{iax_response}", showlegend=False, ) traces.append(trace_r) # calculate subplot ylims = [ ( min([sim.min(), o[tmin:tmax].min()]), max([sim.max(), o[tmin:tmax].max()]), ), (res.min(), res.max()), ] # use residuals (b/c always bigger than noise) 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 = np.asarray(_get_height_ratios(ylims)) # claim at least 1% figure height for zero contributions for visibility hrs = hrs.clip(min=0.01 * np.sum(hrs)) # subplot positions and spacing dx = 0.015 dy = 0.01 # calculate whitespace wspace = (2 * len(hrs) - 2) * dy # scale plot heights hrs_frac = hrs / np.sum(hrs) * (1 - wspace) # add whitespace hrs_frac[[0, -1]] += dy hrs_frac[1:-1] += 2 * dy # calculate y-mid positions (between plots) y_pos = 1 - np.cumsum(np.concatenate([np.zeros(1), hrs_frac])) x_pos = 0.67 # get tops and bottoms of axes from y-position array ytops = y_pos[:-1].copy() ytops[1:] -= dy # half of whitespace by lowering tops by dy ybots = y_pos[1:].copy() ybots[:-1] += dy # create other half of whitespace by raising bottoms by dy ybots[ybots < 0] = 0 # floating point issues, should always be > 0 # parameter table p = self._model.parameters.copy().loc[:, ["name", "optimal", "stderr"]] p.loc[:, "name"] = p.index optimal = p.loc[:, "optimal"].copy() stderr = p.loc[:, "stderr"] / optimal p["optimal"] = p["optimal"].astype(str) p.loc[:, "optimal"] = optimal.apply(_table_formatter_params) p["stderr"] = p["stderr"].astype(str) p.loc[:, "stderr"] = stderr.abs().apply(_table_formatter_stderr) tab = go.Table( domain=dict(x=[x_pos + dx, 1.0], y=[y_pos[2] + dy, 1.0]), header=dict( values=[ "<b>Parameter</b>", "<b>Optimal</b>", "<b>Std. Err.</b>", ], font=dict(size=12), align=["left", "center", "center"], height=40, ), cells=dict( values=[p[k].tolist() for k in p.columns], align=["left", "right", "right"], height=30, ), columnwidth=[100, 70, 70], ) traces.append(tab) layout_dict = dict( # top left (row 0, col 0) - oseries/simulation plot xaxis=dict( domain=[0.0, x_pos - dx], anchor=f"y{nrows}", ), yaxis=dict( domain=[ybots[0], ytops[0]], anchor="x", ), # row 1, col 0 - residuals/noise plot xaxis2=dict( domain=[0.0, x_pos - dx], anchor="y2", ), yaxis2=dict( domain=[ybots[1], ytops[1]], anchor="x2", scaleanchor="y", ), ) # add layout for stressmodels # unfortunately I can only get it to work with numbering down the # columns, e.g. # ax1 table # ax2 table # ax3 ax5 # ax4 ax6 # also order is important, so first all contributions, then step responses # add contributions axes for ism in range(nsm): irow = 2 + ism # 2, 3, 4, ..., (0-based) iax_contrib = 3 + ism # 3, 4, 5, ..., (1-based) # contributions subplot layout_dict[f"xaxis{iax_contrib}"] = dict( domain=[0.0, x_pos - dx], anchor=f"y{iax_contrib}", ) layout_dict[f"yaxis{iax_contrib}"] = dict( domain=[ybots[irow], ytops[irow]], anchor=f"x{iax_contrib}", scaleanchor="y", ) # add response axes for ism in range(nsm): irow = 2 + ism # 2, 3, 4, ..., (0-based) iax_response = 3 + nsm + ism # step response subplot layout_dict[f"xaxis{iax_response}"] = dict( domain=[x_pos + dx, 1], anchor=f"y{naxes}", ) layout_dict[f"yaxis{iax_response}"] = dict( domain=[ybots[irow], ytops[irow]], anchor=f"x{3+nsm}", ) layout = go.Layout(layout_dict) fig = go.Figure(data=traces, layout=layout) # add titles for subplots rnlabel = [ "residuals / noise" if self._model.settings["noise"] else "residuals" ] labels = rnlabel + list(self._model.stressmodels.keys()) for i, lbl in enumerate(labels): fig.add_annotation( xref="paper", yref="paper", x=0.015, y=y_pos[i + 1], xanchor="left", yanchor="middle", showarrow=False, text=lbl, font=dict( size=12, ), align="left", ) # add table title fig.add_annotation( xref="paper", yref="paper", x=x_pos + dx + 0.015, y=1.015, xanchor="left", yanchor="middle", showarrow=False, text="Model parameters (n<sub>c</sub>=7)", font=dict( size=12, ), align="left", ) # add step response title fig.add_annotation( xref="paper", yref="paper", x=x_pos + dx + 0.015, y=y_pos[2], xanchor="left", yanchor="middle", showarrow=False, text="Step response", font=dict( size=12, ), align="left", ) # set size, axes labels and legend position fig.update_layout( xaxis={"range": [tmin, tmax]}, yaxis={"title": "[m NAP]"}, # oseries, simulation yaxis2={"title": "[m]"}, # residuals legend={ "traceorder": "reversed+grouped", "orientation": "h", "xanchor": "left", "yanchor": "bottom", "x": 0.0, "y": 1.00, }, # height=1000, # width=1000, margin=dict(t=60, b=20, l=10, r=25), dragmode="pan", ) update_labels = {f"yaxis{i}": {"title": "[m]"} for i in range(3, nrows + 1)} update_labels[f"xaxis{nrows+1}"] = {"title": "time [d]"} fig.update_layout(**update_labels) return fig
[docs] def diagnostics(self): """Plotly version of pastas.Model.plots.diagnostics(). Parameters ---------- ml : pastas.Model model to plot results for Returns ------- fig : plotly.Figure plotly figure with model diagnostics """ # prepare data sim = self._model.simulate() if self._model.settings["noise"]: series = self._model.noise() resnoisename = "noise" else: series = self._model.residuals() resnoisename = "residuals" df_acf = acf(series, full_output=True) x = df_acf.index.total_seconds() / (24 * 60 * 60) conf = df_acf["conf"].rolling(10, min_periods=1).mean().values if self._model.interpolate_simulation: sim_interpolated = np.interp(series.index.asi8, sim.index.asi8, sim.values) sim = pd.Series(index=series.index, data=sim_interpolated) sim = sim.loc[series.index] # let the plotting begin fig = make_subplots( rows=4, cols=2, specs=[ [{"colspan": 2}, None], [{"colspan": 2}, None], [{}, {}], [{}, {}], ], subplot_titles=[ f"{resnoisename} (&#956;={series.mean():.1f})", "Autocorrelation", "Histogram", "Probability plot", "Heteroscedasticity", "", ], horizontal_spacing=0.1, vertical_spacing=0.075, ) # Noise fig.add_trace( go.Scatter( x=series.index, y=series.values, line_color="rgba(31,119,180,1)", showlegend=False, name=resnoisename, ), row=1, col=1, ) # ACF fig.add_trace( go.Scatter( x=x, y=conf, mode="lines", fillcolor="rgba(32,146,230,0.3)", line_color="rgba(255,255,255,0)", showlegend=False, name="CI", hoverinfo="skip", ), row=2, col=1, ) fig.add_trace( go.Scatter( x=x, y=-conf, mode="lines", fillcolor="rgba(32,146,230,0.3)", fill="tonexty", line_color="rgba(255,255,255,0)", legendgroup="1", showlegend=False, name="CI", hoverinfo="skip", ), row=2, col=1, ) fig.add_trace( go.Bar( x=x, y=df_acf.iloc[:, 0], marker={"color": ["black"] * df_acf.index.size}, legendgroup="0", showlegend=False, width=0.9, name="ACF", ), row=2, col=1, ) # normality _, bins = np.histogram(series.values, bins=50) bins = 0.5 * (bins[:-1] + bins[1:]) fig.add_trace( go.Histogram( x=series.values, ybins={"size": 50}, name="Histogram", showlegend=False, histnorm="probability density", marker=dict(color="rgba(31,119,180,1)"), ), row=3, col=1, ) pdf = norm.pdf(bins, series.mean(), series.std()) fig.add_trace( go.Scatter( x=bins, y=pdf, mode="lines", name="PDF", line=dict( dash="dash", color="black", ), showlegend=False, ), row=3, col=1, ) (osm, osr), (slope, intercept, r) = probplot(series, dist="norm", rvalue=False) fig.add_trace( go.Scatter( x=osm, y=osr, mode="markers", marker={"color": "rgba(31,119,180,1)"}, showlegend=False, name="probplot", ), row=3, col=2, ) fig.add_trace( go.Scatter( x=osm, y=slope * osm + intercept, mode="lines", line={"color": "black"}, showlegend=False, name="fit", ), row=3, col=2, ) fig.add_annotation( xref="x4 domain", yref="y4 domain", x=0.05, y=0.95, xanchor="left", yanchor="top", showarrow=False, text=f"R<sup>2</sup>={r:.3f}", font=dict( size=12, ), align="left", ) # heteroscedasticity fig.add_trace( go.Scatter( name="heteroscedasticity", x=sim, y=series, mode="markers", showlegend=False, marker=dict( size=5, color="rgba(31,119,180,0.5)", line=dict( color="black", width=0.25, ), ), ), row=4, col=1, ) fig.add_trace( go.Scatter( name="heteroscedasticity", x=sim, y=np.sqrt(series.abs()), mode="markers", showlegend=False, marker=dict( size=5, color="rgba(31,119,180,0.5)", line=dict( color="black", width=0.25, ), ), ), row=4, col=2, ) # update x-axes, y-axes fig.update_yaxes(title_text=resnoisename, title_standoff=0, row=1, col=1) fig.update_xaxes(title_text="Lags [d]", title_standoff=0, row=2, col=1) fig.update_yaxes(title_text="ACF [-]", title_standoff=0, row=2, col=1) fig.update_yaxes( title_text="Probability density", title_standoff=0, row=3, col=1 ) fig.update_xaxes( title_text="Theoretical quantiles", title_standoff=0, row=3, col=2 ) fig.update_yaxes(title_text="Ordered values", title_standoff=0, row=3, col=2) fig.update_xaxes(title_text="Simulated values", title_standoff=0, row=4, col=1) fig.update_yaxes(title_text="Residuals", title_standoff=0, row=4, col=1) fig.update_yaxes( title_text=r"&#8730; Residuals", title_standoff=0, row=4, col=2 ) fig.update_xaxes(title_text="Simulated values", title_standoff=0, row=4, col=2) # update titles fig.layout.annotations[0].update(x=0.05, font={"size": 13}) fig.layout.annotations[1].update(x=0.05, font={"size": 13}) fig.layout.annotations[2].update(x=0.05, font={"size": 13}) fig.layout.annotations[3].update(x=0.60, font={"size": 13}) fig.layout.annotations[4].update(x=0.05, font={"size": 13}) # fig.update_layout( legend={ "traceorder": "grouped", "orientation": "h", "xanchor": "left", "yanchor": "bottom", "x": 0.0, "y": 1.00, }, # width=750, # height=1000, margin=dict(t=60, b=20, l=25, r=10), ) return fig