From c7e5d3c457f4f1428f9791e656b9a6dcc0b2c539 Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Tue, 2 Apr 2024 22:00:29 +0200 Subject: [PATCH 1/9] Add bokeh plots --- pastas/extensions/__init__.py | 7 + pastas/plotting/bokeh.py | 288 ++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 pastas/plotting/bokeh.py 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/plotting/bokeh.py b/pastas/plotting/bokeh.py new file mode 100644 index 00000000..e51e9452 --- /dev/null +++ b/pastas/plotting/bokeh.py @@ -0,0 +1,288 @@ +from bokeh.plotting import figure, show +from bokeh.models import ( + ColumnDataSource, + DataTable, + TableColumn, + StringFormatter, + RangeTool, + ScientificFormatter, +) +from bokeh.layouts import row, column + +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) + 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 (R$$^2$$ = {:.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) + 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) + 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="Simulation", + ) + 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.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.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 == data.columns.size: + x_axis_location = "bottom" + 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="#efefef", + ) + + 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 From 51a100c92799b6e0faa022145301a55346288631 Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 07:31:48 +0200 Subject: [PATCH 2/9] Update interactive notebook --- ...ve_plots.ipynb => interactive_plots.ipynb} | 66 +++++++++++++++---- pastas/plotting/bokeh.py | 28 +++++--- pastas/plotting/plotly.py | 2 +- 3 files changed, 76 insertions(+), 20 deletions(-) rename doc/examples/{plotly_interactive_plots.ipynb => interactive_plots.ipynb} (75%) diff --git a/doc/examples/plotly_interactive_plots.ipynb b/doc/examples/interactive_plots.ipynb similarity index 75% rename from doc/examples/plotly_interactive_plots.ipynb rename to doc/examples/interactive_plots.ipynb index c604cd1d..7c1ab121 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", @@ -148,8 +149,7 @@ "metadata": {}, "outputs": [], "source": [ - "fig = ml.plotly.plot()\n", - "fig" + "fig = ml.plotly.plot()" ] }, { @@ -187,6 +187,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 +246,7 @@ }, "anaconda-cloud": {}, "kernelspec": { - "display_name": "pastas_dev", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -216,7 +260,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.11.3" }, "vscode": { "interpreter": { diff --git a/pastas/plotting/bokeh.py b/pastas/plotting/bokeh.py index e51e9452..ec2f530f 100644 --- a/pastas/plotting/bokeh.py +++ b/pastas/plotting/bokeh.py @@ -1,13 +1,13 @@ -from bokeh.plotting import figure, show +from bokeh.layouts import column, row from bokeh.models import ( ColumnDataSource, DataTable, - TableColumn, - StringFormatter, RangeTool, ScientificFormatter, + StringFormatter, + TableColumn, ) -from bokeh.layouts import row, column +from bokeh.plotting import figure, show from pastas.extensions import register_model_accessor @@ -74,7 +74,7 @@ def plot(self, tmin=None, tmax=None, height=300, width=600, show_plot=True): """ - data = self._model.get_output_series(tmin=tmin, tmax=tmax) + data = self._model.get_output_series(tmin=tmin, tmax=tmax, split=False) source = ColumnDataSource(data) rsq = self._model.stats.rsq(tmin=tmin, tmax=tmax) @@ -140,7 +140,7 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): >>> fig = ml.bokeh.results() """ - data = self._model.get_output_series(tmin=tmin, tmax=tmax) + 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) @@ -185,6 +185,15 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): 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", @@ -196,6 +205,7 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): 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 @@ -229,10 +239,11 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): rfunc_plot = None for i, smname in enumerate(self._model.stressmodels.keys(), start=2): - if i == data.columns.size: - x_axis_location = "bottom" + 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, @@ -248,6 +259,7 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): xrange = rfunc_plot.x_range else: xrange = False + rfunc_plot = figure( x_axis_label=None, toolbar_location=None, 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") From 38a034898c9f465932d45ebdf1fa1d718cc3baa6 Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 07:42:14 +0200 Subject: [PATCH 3/9] Deprecate contributions_pie --- pastas/decorators.py | 2 +- pastas/plotting/modelplots.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/plotting/modelplots.py b/pastas/plotting/modelplots.py index b20b2d5f..ad2f0aab 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 model_tmin_tmax, PastasDeprecationWarning 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, From 02e9c91a4535984a815cfd2c43bdcd2b522449be Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 07:45:43 +0200 Subject: [PATCH 4/9] Update pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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]", From 80975dbaa11c4635704633dea7c60971db28661c Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 07:57:13 +0200 Subject: [PATCH 5/9] gotta love isort --- pastas/plotting/modelplots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pastas/plotting/modelplots.py b/pastas/plotting/modelplots.py index ad2f0aab..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, PastasDeprecationWarning +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, From 0fb6ce1fada0d1d24178630915426bc8a0a868d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dav=C3=ADd=20Brakenhoff?= Date: Wed, 3 Apr 2024 11:31:46 +0200 Subject: [PATCH 6/9] show ml.plotly.plot() figure --- doc/examples/interactive_plots.ipynb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/examples/interactive_plots.ipynb b/doc/examples/interactive_plots.ipynb index 7c1ab121..35464b09 100644 --- a/doc/examples/interactive_plots.ipynb +++ b/doc/examples/interactive_plots.ipynb @@ -149,7 +149,8 @@ "metadata": {}, "outputs": [], "source": [ - "fig = ml.plotly.plot()" + "fig = ml.plotly.plot()\n", + "fig" ] }, { @@ -260,7 +261,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.10.12" }, "vscode": { "interpreter": { From 0f671baa47b078f9e57348c43b24f6b008bfbb6c Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 15:22:24 +0200 Subject: [PATCH 7/9] Update range_tool background and rsq --- pastas/plotting/bokeh.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pastas/plotting/bokeh.py b/pastas/plotting/bokeh.py index ec2f530f..ebc77c4b 100644 --- a/pastas/plotting/bokeh.py +++ b/pastas/plotting/bokeh.py @@ -103,7 +103,7 @@ def plot(self, tmin=None, tmax=None, height=300, width=600, show_plot=True): "index", "Simulation", source=source, - legend_label=r"Simulation (R$$^2$$ = {:.2f})".format(rsq), + legend_label=r"Simulation (R2 = {:.2f})".format(rsq), line_width=2, ) p.legend.ncols = 2 @@ -145,6 +145,8 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): 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( @@ -170,7 +172,7 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): "index", "Simulation", source=source, - legend_label="Simulation", + legend_label=r"Simulation (R2 = {:.2f})".format(rsq), ) p.legend.ncols = 2 @@ -283,7 +285,7 @@ def results(self, tmin=None, tmax=None, height=500, width=800, show_plot=True): y_axis_type=None, tools="", toolbar_location=None, - background_fill_color="#efefef", + background_fill_color="#ffffff", ) range_tool = RangeTool(x_range=p.x_range) From 5b0e56b699d7b263f7a69d09c41fff9cfa0569ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dav=C3=ADd=20Brakenhoff?= Date: Wed, 3 Apr 2024 16:43:14 +0200 Subject: [PATCH 8/9] add sort=True for future pandas versions (>= 2.2) --- pastas/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From efe9f9a717d6c1e77364ac11663ca34ab4a671cd Mon Sep 17 00:00:00 2001 From: Raoul Collenteur Date: Wed, 3 Apr 2024 17:13:01 +0200 Subject: [PATCH 9/9] Make plot docs better --- pastas/plotting/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 """