diff --git a/doc/examples/plotly_interactive_plots.ipynb b/doc/examples/interactive_plots.ipynb similarity index 76% rename from doc/examples/plotly_interactive_plots.ipynb rename to doc/examples/interactive_plots.ipynb index c604cd1d..35464b09 100644 --- a/doc/examples/plotly_interactive_plots.ipynb +++ b/doc/examples/interactive_plots.ipynb @@ -6,12 +6,9 @@ "source": [ "# Interactive plots \n", "\n", - "*D.A. Brakenhoff (Artesia Water 2023)*\n", + "*D.A. Brakenhoff (Artesia Water 2023) and R.A. Collenteur (Eawag, 2024)*\n", "\n", - "This notebook shows how interactive plots can be created using the Plotly extension.\n", - "\n", - "Extensions can be used to add functionality to existing classes in Pastas. In this\n", - "example the plotly extension is registered to the `pastas.Model` class, allowing us to\n", + "This notebook shows how interactive plots can be created using the [Plotly](https://plotly.com) and [Bokeh](https://docs.bokeh.org/en/latest/index.html) extensions. Extensions can be used to add functionality to existing classes in Pastas. In this example the plotly extension is registered to the `pastas.Model` class, allowing us to\n", "create interactive figures." ] }, @@ -22,10 +19,13 @@ "outputs": [], "source": [ "# import the requisite packages\n", - "\n", "import pandas as pd\n", "import pastas as ps\n", "\n", + "# To show bokeh plots inline in a jupyter notebook\n", + "from bokeh.io import output_notebook\n", + "output_notebook()\n", + "\n", "ps.set_log_level(\"ERROR\")" ] }, @@ -33,6 +33,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "## Create a Pastas model\n", "Read in the heads, precipitation and evaporation data from the first example notebook." ] }, @@ -45,7 +46,6 @@ "# read observations and create the time series model\n", "obs = pd.read_csv(\"data/head_nb1.csv\", index_col=0, parse_dates=True).squeeze(\"columns\")\n", "\n", - "\n", "# read weather data\n", "rain = pd.read_csv(\"data/rain_nb1.csv\", index_col=0, parse_dates=True).squeeze(\n", " \"columns\"\n", @@ -84,6 +84,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "## Making plotly plots\n", "In order to add the interactive plotting extension to the `pastas.Model` class, we need\n", "to register the extension. The Plotly extension is already part of the pastas plotting\n", "library, but since it requires the `plotly` package as an optional dependency, it is\n", @@ -187,6 +188,50 @@ "fig = ml.plotly.diagnostics()\n", "fig.update_layout(height=800, width=800)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Making Bokeh plots\n", + "\n", + "Using Bokeh plots works similar to Plotly plots. First we register the Bokeh extension, which requires Bokeh to be installed. Also, make sure to run `output_notebook` (see top of this notebook) to show the figures inline in a Jupyter Notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# register plotly extension, requires bokeh to be installed\n", + "ps.extensions.register_bokeh()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ml.bokeh.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The results plot is available from `ml.bokeh.results` as follows:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ml.bokeh.results(height=350)" + ] } ], "metadata": { @@ -202,7 +247,7 @@ }, "anaconda-cloud": {}, "kernelspec": { - "display_name": "pastas_dev", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, diff --git a/pastas/decorators.py b/pastas/decorators.py index 1d3fca9b..26d764bd 100644 --- a/pastas/decorators.py +++ b/pastas/decorators.py @@ -67,7 +67,7 @@ def PastasDeprecationWarning(function: Function) -> Function: @wraps(function) def _function(*args, **kwargs): logger.warning( - "Method is deprecated and will be removed in Pastas version 1.2." + "Method is deprecated since 1.5 and will be removed in Pastas version 1.6" ) return function(*args, **kwargs) diff --git a/pastas/extensions/__init__.py b/pastas/extensions/__init__.py index e5864e2e..2a98b8f6 100644 --- a/pastas/extensions/__init__.py +++ b/pastas/extensions/__init__.py @@ -10,3 +10,10 @@ def register_plotly(): from pastas.plotting.plotly import Plotly logger.info("Registered plotly extension in Model class, e.g. `ml.plotly.plot()`.") + + +def register_bokeh(): + """Register Bokeh extension for pastas.Model class for interactive plotting.""" + from pastas.plotting.bokeh import Bokeh + + logger.info("Registered bokeh extension in Model class, e.g. `ml.bokeh.plot()`.") diff --git a/pastas/model.py b/pastas/model.py index d6a87460..8ebbafbe 100644 --- a/pastas/model.py +++ b/pastas/model.py @@ -1465,7 +1465,7 @@ def get_output_series( for contrib in contribs: df.append(contrib) - df = concat(df, axis=1) + df = concat(df, axis=1, sort=True) return df def _get_response( diff --git a/pastas/plotting/__init__.py b/pastas/plotting/__init__.py index 0093082b..948678f7 100644 --- a/pastas/plotting/__init__.py +++ b/pastas/plotting/__init__.py @@ -7,9 +7,10 @@ :nosignatures: plots - modelplots + modelplots.Plotting + plotly.Plotly + bokeh.Bokeh modelcompare plotutil - plotly """ diff --git a/pastas/plotting/bokeh.py b/pastas/plotting/bokeh.py new file mode 100644 index 00000000..ebc77c4b --- /dev/null +++ b/pastas/plotting/bokeh.py @@ -0,0 +1,302 @@ +from bokeh.layouts import column, row +from bokeh.models import ( + ColumnDataSource, + DataTable, + RangeTool, + ScientificFormatter, + StringFormatter, + TableColumn, +) +from bokeh.plotting import figure, show + +from pastas.extensions import register_model_accessor + + +@register_model_accessor("bokeh") +class Bokeh: + """Extension class for interactive bokeh figures for pastas Models. + + Usage + ----- + >>> ps.extensions.register_bokeh() + INFO: Registered bokeh plotting methods in Model class, e.g. `ml.bokeh.plot()`. + >>> fig = ml.bokeh.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()` + + Notes + ----- + The `bokeh` extension is registered in the `Model` class by calling the + `register_bokeh()` function. To work in Juptyer notebooks, the + `bokeh.io.output_notebook()` function should be called before plotting. The `bokeh` + extension is not registered by default, and should be called explicitly. Check the + bokeh documentation for more information on how to interact with the plots. + + """ + + def __init__(self, model): + self._model = model + + def plot(self, tmin=None, tmax=None, height=300, width=600, show_plot=True): + """Plot the observations and model simulation. + + 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 + height : int, optional + height of the plot, by default 500 + width : int, optional + width of the plot, by default 800 + show_plot : bool, optional + Show the plot (i.e., in Jupyter Notebooks), by default True + + Returns + ------- + p : bokeh.plotting.figure + Bokeh figure with the observations and model simulation. + + Examples + -------- + >>> ps.extensions.register_bokeh() + INFO: Registered bokeh plotting methods in Model class, e.g. `ml.bokeh.plot()`. + >>> + >>> fig = ml.bokeh.plot() + + """ + + data = self._model.get_output_series(tmin=tmin, tmax=tmax, split=False) + source = ColumnDataSource(data) + rsq = self._model.stats.rsq(tmin=tmin, tmax=tmax) + + TOOLS = "zoom_in,zoom_out,reset,pan,xwheel_zoom,box_zoom,undo" + + p = figure( + title="Pastas Model", + y_axis_label="Head", + x_axis_location=None, + tools=TOOLS, + width=width, + height=height, + x_axis_type="datetime", + toolbar_location="above", + ) + + p.scatter( + "index", + "Head_Calibration", + source=source, + legend_label="Observations", + color="black", + alpha=0.7, + ) + p.line( + "index", + "Simulation", + source=source, + legend_label=r"Simulation (R2 = {:.2f})".format(rsq), + line_width=2, + ) + p.legend.ncols = 2 + + if show_plot: + show(p) + return p + + def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): + """Overview of the results of the pastas model. + + 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 + height : int, optional + height of the plot, by default 500 + width : int, optional + width of the plot, by default 800 + show_plot : bool, optional + Show the plot (i.e., in Jupyter Notebooks), by default True + + Returns + ------- + grid : bokeh.layouts.column + Bokeh layout with the results of the pastas model. + + Examples + -------- + >>> ps.extensions.register_bokeh() + INFO: Registered bokeh plotting methods in Model class, e.g. `ml.bokeh.plot()`. + >>> fig = ml.bokeh.results() + + """ + data = self._model.get_output_series(tmin=tmin, tmax=tmax, split=False) + ranges = data.max() - data.min() + ranges = ranges.drop([ranges.iloc[:2].idxmin(), "Noise"]) + heights = (ranges / ranges.sum() * (height - 50)).values.astype(int) + source = ColumnDataSource(data) + rsq = self._model.stats.rsq(tmin=tmin, tmax=tmax) + + TOOLS = "zoom_in,zoom_out,reset,pan,xwheel_zoom,box_zoom,undo" + + p = figure( + title="Pastas Model", + y_axis_label="Head", + x_axis_location=None, + tools=TOOLS, + width=int(0.75 * width), + height=heights[0], + x_axis_type="datetime", + toolbar_location="above", + ) + + p.scatter( + "index", + "Head_Calibration", + source=source, + legend_label="Observations", + color="black", + alpha=0.7, + ) + p.line( + "index", + "Simulation", + source=source, + legend_label=r"Simulation (R2 = {:.2f})".format(rsq), + ) + p.legend.ncols = 2 + + # Residuals + res_plot = figure( + y_axis_label="Residuals", + toolbar_location=None, + tools=TOOLS, + x_range=p.x_range, + width=int(0.75 * width), + height=heights[2], + x_axis_type="datetime", + x_axis_location=None, + ) + + res_plot.scatter( + "index", + "Residuals", + source=source, + color="black", + alpha=0.7, + legend_label="Residuals", + ) + res_plot.line( + "index", + "Residuals", + source=source, + color="black", + alpha=0.7, + legend_label="Residuals", + ) + + if self._model.settings["noise"]: + res_plot.line("index", "Noise", source=source, legend_label="Noise") + res_plot.scatter("index", "Noise", source=source, legend_label="Noise") + + res_plot.legend.ncols = 2 + + # Parameter Table + df = ColumnDataSource(self._model.parameters.loc[:, ["optimal"]]) + columns = [ + TableColumn( + field="index", + title="Name", + formatter=StringFormatter(font_style="bold"), + ), + TableColumn( + field="optimal", + title="Optimal", + formatter=ScientificFormatter(precision=2), + ), + ] + table = DataTable( + source=df, + columns=columns, + editable=False, + index_position=None, + width=int(0.25 * width), + height=heights[0] + heights[2] - 10, + ) + + left_column = [p, res_plot] + right_column = [table] + + # Contributions + rfunc_plot = None + + for i, smname in enumerate(self._model.stressmodels.keys(), start=2): + if i == int(len(self._model.stressmodels) + 1): + x_axis_location = "below" + else: + x_axis_location = None + + contrib_plot = figure( + y_axis_label="Rise", + toolbar_location=None, + tools=TOOLS, + x_axis_location=x_axis_location, + width=int(0.75 * width), + height=heights[i], + x_axis_type="datetime", + x_range=p.x_range, + ) + + if rfunc_plot is not None: + xrange = rfunc_plot.x_range + else: + xrange = False + + rfunc_plot = figure( + x_axis_label=None, + toolbar_location=None, + x_axis_location=x_axis_location, + width=int(0.25 * width), + height=heights[i], + ) + + contrib_plot.line("index", smname, source=source) + response = self._model.get_step_response(smname) + rfunc_plot.line(response.index, response.values) + + left_column.append(contrib_plot) + right_column.append(rfunc_plot) + + select = figure( + height=50, + width=int(0.75 * width), + y_range=p.y_range, + x_axis_type="datetime", + y_axis_type=None, + tools="", + toolbar_location=None, + background_fill_color="#ffffff", + ) + + range_tool = RangeTool(x_range=p.x_range) + select.line("index", "Simulation", source=source) + select.add_tools(range_tool) + left_column.append(select) + + layout = row(column(left_column), column(right_column)) + grid = column(layout, width=width, height=height) + + if show_plot: + show(grid) + + return grid diff --git a/pastas/plotting/modelplots.py b/pastas/plotting/modelplots.py index b20b2d5f..6caf4a03 100644 --- a/pastas/plotting/modelplots.py +++ b/pastas/plotting/modelplots.py @@ -11,7 +11,7 @@ from matplotlib.ticker import LogFormatter, MultipleLocator from pandas import Series, Timestamp, concat -from pastas.decorators import model_tmin_tmax +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, @@ -748,6 +748,7 @@ def stresses( return axes + @PastasDeprecationWarning @model_tmin_tmax def contributions_pie( self, diff --git a/pastas/plotting/plotly.py b/pastas/plotting/plotly.py index 1be51348..0b897965 100644 --- a/pastas/plotting/plotly.py +++ b/pastas/plotting/plotly.py @@ -19,7 +19,7 @@ class Plotly: Usage ----- - >>> ps.utils.register_plotly_extension() + >>> 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") diff --git a/pyproject.toml b/pyproject.toml index d1225ff7..50d98e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ ci = [ "emcee", "tqdm", "plotly", + "bokeh >= 3.0", ] rtd = [ "pastas[solvers]",