From 24d1a4e4f4b0f84c9536e9ce7b1ab5acd0e066dd Mon Sep 17 00:00:00 2001 From: Eilert Skram Date: Tue, 14 Jul 2026 11:52:12 +0200 Subject: [PATCH 1/4] Create DistributionOptions Preperations for the new plot. The different options will allow the user to toggle the plot types to add to the figure and what type of axis to display. Each plot option contains helper text Intention is to replace the single line scatter-points with rug plot to better visualize the distribution --- src/ert/gui/plotting/plot_window.py | 5 + src/ert/gui/plotting/utils/plot_context.py | 29 ++ .../widgets/plot_controls/__init__.py | 2 + .../plot_controls/distribution_options.py | 122 +++++++ .../ert_plots/test_distribution_plot.py | 341 ++++++++++++++++++ .../widgets/test_distribution_options.py | 83 +++++ 6 files changed, 582 insertions(+) create mode 100644 src/ert/gui/plotting/widgets/plot_controls/distribution_options.py create mode 100644 tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py create mode 100644 tests/ert/unit_tests/gui/plotting/widgets/test_distribution_options.py diff --git a/src/ert/gui/plotting/plot_window.py b/src/ert/gui/plotting/plot_window.py index c80b1a7c995..c324cc93e70 100644 --- a/src/ert/gui/plotting/plot_window.py +++ b/src/ert/gui/plotting/plot_window.py @@ -68,6 +68,7 @@ EverestControlsPlotOptions, GeneralPlotOptions, StatisticsOptions, + DistributionOptions, ) from .widgets.plot_ensemble_selection_widget import EnsembleSelectionWidget from .widgets.plot_widget import Plotter, PlotWidget @@ -309,6 +310,7 @@ def __init__( self._general_options.titleEditRequested.connect(self._edit_title) self._boxplot_options = BoxplotOptions(self.update_plot) self._statistics_options = StatisticsOptions(self.update_plot) + self._distribution_options = DistributionOptions(self.update_plot) right_container = QWidget() right_layout = create_group_layout( @@ -319,6 +321,7 @@ def __init__( self._everest_controls_group, self._boxplot_options.get_widget(), self._statistics_options.get_widget(), + self._distribution_options.get_widget(), ] ) right_layout.addStretch(1) @@ -329,6 +332,7 @@ def __init__( self._everest_controls_plot_options.get_widget().setVisible(False) self._boxplot_options.get_widget().setVisible(False) self._statistics_options.get_widget().setVisible(False) + self._distribution_options.get_widget().setVisible(False) self._data_type_keys_widget.selectDefault() self.setCentralWidget(self._central_tab) @@ -596,6 +600,7 @@ def fetch_data( ) self._boxplot_options.update_plot_context(plot_context) self._everest_controls_plot_options.update_plot_context(plot_context) + self._distribution_options.update_plot_context(plot_context) # Check if key is a history key. # If it is, it already has the data it needs. diff --git a/src/ert/gui/plotting/utils/plot_context.py b/src/ert/gui/plotting/utils/plot_context.py index 45c845a5884..31ad86520d0 100644 --- a/src/ert/gui/plotting/utils/plot_context.py +++ b/src/ert/gui/plotting/utils/plot_context.py @@ -62,6 +62,11 @@ def __init__( self._box_plot: bool = True self._mean: bool = True + # Distribution plot + self._histogram: bool = True + self._rug_plot: bool = True + self._gkde_plot: bool = True + self._plot_type: PlotType | None = None @property @@ -187,3 +192,27 @@ def mean(self) -> bool: @mean.setter def mean(self, value: bool) -> None: self._mean = value + + @property + def gkde_plot(self) -> bool: + return self._gkde_plot + + @gkde_plot.setter + def gkde_plot(self, value: bool) -> None: + self._gkde_plot = value + + @property + def rug_plot(self) -> bool: + return self._rug_plot + + @rug_plot.setter + def rug_plot(self, value: bool) -> None: + self._rug_plot = value + + @property + def histogram(self) -> bool: + return self._histogram + + @histogram.setter + def histogram(self, value: bool) -> None: + self._histogram = value diff --git a/src/ert/gui/plotting/widgets/plot_controls/__init__.py b/src/ert/gui/plotting/widgets/plot_controls/__init__.py index 65764c57a9d..6c42a9d472b 100644 --- a/src/ert/gui/plotting/widgets/plot_controls/__init__.py +++ b/src/ert/gui/plotting/widgets/plot_controls/__init__.py @@ -1,5 +1,6 @@ from .boxplot_options import BoxplotOptions from .custom_palette_dialog import CustomPaletteDialog +from .distribution_options import DistributionOptions from .everest_controls_plot_options import EverestControlsPlotOptions from .general_options import GeneralPlotOptions from .plot_color_palette_selector import PlotColorPaletteSelector @@ -8,6 +9,7 @@ __all__ = [ "BoxplotOptions", "CustomPaletteDialog", + "DistributionOptions", "EverestControlsPlotOptions", "GeneralPlotOptions", "PlotColorPaletteSelector", diff --git a/src/ert/gui/plotting/widgets/plot_controls/distribution_options.py b/src/ert/gui/plotting/widgets/plot_controls/distribution_options.py new file mode 100644 index 00000000000..2095e2032bc --- /dev/null +++ b/src/ert/gui/plotting/widgets/plot_controls/distribution_options.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import ( + QCheckBox, +) + +if TYPE_CHECKING: + from ert.gui.plotting.utils.plot_context import PlotContext + +from ert.gui.plotting.utils.qt_creator import create_group_layout +from ert.gui.plotting.widgets.collapsible_section import CollapsibleSection + +logger = logging.getLogger(__name__) + +NAME_AND_TOOLTIP = [ + ( + "histogram_checkbox", + "Show histogram", + "Adds a histogram of the data to the plot.\nDisplayed as counts.", + ), + ( + "gkde_checkbox", + "Show estimated density", + ( + "Adds a Gaussian kernel density estimate to the plot." + "\nDisplays a line for the probability density function" + " of the data for each ensemble." + ), + ), + ( + "rug_checkbox", + "Show individual points", + ( + "Displays the distribution as a rug plot for each ensemble." + "\nIf histogram/Gaussian KDE is enabled, " + "the rug plots will be plotted below the main plot." + ), + ), +] + + +class DistributionOptions: + def __init__(self, connection_point: Callable[..., object]) -> None: + self._logged_options: set[str] = set() + + self._histogram, self._gkde, self._rug_plot = [ + self._add_checkbox(obj_name, label, tooltip, connection_point) + for obj_name, label, tooltip in NAME_AND_TOOLTIP + ] + self._distribution_options = CollapsibleSection( + "Distribution options", + create_group_layout( + [ + self._histogram, + self._gkde, + self._rug_plot, + ] + ), + expanded=True, + ) + + @property + def histogram_checkbox_state(self) -> bool: + return self._histogram.isChecked() + + @histogram_checkbox_state.setter + def histogram_checkbox_state(self, value: bool) -> None: + self._histogram.setChecked(value) + + @property + def gkde_checkbox_state(self) -> bool: + return self._gkde.isChecked() + + @gkde_checkbox_state.setter + def gkde_checkbox_state(self, value: bool) -> None: + self._gkde.setChecked(value) + + @property + def rug_checkbox_state(self) -> bool: + return self._rug_plot.isChecked() + + @rug_checkbox_state.setter + def rug_checkbox_state(self, value: bool) -> None: + self._rug_plot.setChecked(value) + + def get_widget(self) -> CollapsibleSection: + return self._distribution_options + + # Only wish to log the first time a distribution option is used in a session, + # otherwise could risk flooding the log + def _log_usage(self, distribution_option_name: str, _checked: bool) -> None: + if distribution_option_name not in self._logged_options: + logger.info("Plot sidebar option used: '%s'", distribution_option_name) + self._logged_options.add(distribution_option_name) + + def update_plot_context(self, plot_context: PlotContext) -> None: + plot_context.histogram = self.histogram_checkbox_state + plot_context.gkde_plot = self.gkde_checkbox_state + plot_context.rug_plot = self.rug_checkbox_state + + def _add_checkbox( + self, + obj_name: str, + label: str, + tooltip: str, + connection_point: Callable[..., object], + ) -> QCheckBox: + checkbox = QCheckBox(f"{label}") + checkbox.setObjectName(f"{obj_name}") + + checkbox.setToolTip(tooltip) + checkbox.setChecked(True) + + checkbox.stateChanged.connect(connection_point) + checkbox.clicked.connect( + lambda checked: self._log_usage(f"Distribution option: {label}", checked) + ) + return checkbox diff --git a/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py b/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py new file mode 100644 index 00000000000..0abc22161e1 --- /dev/null +++ b/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from matplotlib.container import BarContainer +from matplotlib.figure import Figure + +from ert.gui.plotting.ert_plots.distribution import ( + DistributionPlot, + _array_is_constant, +) +from ert.gui.plotting.plot_api import EnsembleObject +from ert.gui.plotting.utils.plot_config import PlotConfig +from ert.gui.plotting.utils.plot_context import PlotContext + + +def _make_ensemble(name: str = "ensemble_1") -> EnsembleObject: + return EnsembleObject( + name=name, + id=name, + hidden=False, + experiment_name="experiment_1", + started_at="2012-12-10T00:00:00", + ) + + +def _make_context( + ensembles: list[EnsembleObject], + *, + histogram: bool = True, + gkde_plot: bool = True, + rug_plot: bool = True, + by_density: bool = True, + log_scale: bool = False, +) -> PlotContext: + context = PlotContext( + PlotConfig(title="Distribution"), + ensembles=ensembles, + ensembles_color_indexes=list(range(len(ensembles))), + key="A_KEY", + ) + context.histogram = histogram + context.gkde_plot = gkde_plot + context.rug_plot = rug_plot + context.log_scale = log_scale + return context + + +def _plot(context: PlotContext, data_map: dict[EnsembleObject, pd.DataFrame]) -> Figure: + figure = Figure() + DistributionPlot().plot(figure, context, data_map, pd.DataFrame(), {}, None) + return figure + + +def _count_kde_lines(figure: Figure) -> int: + """A Gaussian KDE curve is a solid line with ~1000 evaluated points.""" + return sum( + 1 + for axes in figure.axes + for line in axes.get_lines() + if line.get_marker() in {"", "None"} and np.asarray(line.get_xdata()).size > 100 + ) + + +def _count_histogram_bars(figure: Figure) -> int: + return sum( + len(container) + for axes in figure.axes + for container in axes.containers + if isinstance(container, BarContainer) + ) + + +def _count_rug_marker_lines(figure: Figure) -> int: + return sum( + 1 + for axes in figure.axes + for line in axes.get_lines() + if line.get_marker() == "|" + ) + + +@pytest.fixture +def single_ensemble() -> EnsembleObject: + return _make_ensemble() + + +@pytest.fixture +def varying_data_map( + single_ensemble: EnsembleObject, +) -> dict[EnsembleObject, pd.DataFrame]: + return {single_ensemble: pd.DataFrame({0: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})} + + +def test_that_distribution_plot_shows_message_when_no_plot_option_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=False, rug_plot=False + ) + + figure = _plot(context, varying_data_map) + + assert any("No plot options selected." in text.get_text() for text in figure.texts) + assert figure.axes == [] + + +def test_that_only_histogram_is_rendered_when_only_histogram_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=True, gkde_plot=False, rug_plot=False + ) + + figure = _plot(context, varying_data_map) + + assert _count_histogram_bars(figure) > 0 + assert _count_kde_lines(figure) == 0 + assert _count_rug_marker_lines(figure) == 0 + + +def test_that_only_gkde_is_rendered_when_only_gkde_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=True, rug_plot=False + ) + + figure = _plot(context, varying_data_map) + + assert _count_kde_lines(figure) == 1 + assert _count_histogram_bars(figure) == 0 + assert _count_rug_marker_lines(figure) == 0 + + +def test_that_only_rug_is_rendered_when_only_rug_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=False, rug_plot=True + ) + + figure = _plot(context, varying_data_map) + + assert _count_rug_marker_lines(figure) == 1 + assert _count_histogram_bars(figure) == 0 + assert _count_kde_lines(figure) == 0 + + +def test_that_histogram_and_gkde_are_rendered_together_without_rug( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=True, gkde_plot=True, rug_plot=False + ) + + figure = _plot(context, varying_data_map) + + assert _count_histogram_bars(figure) > 0 + assert _count_kde_lines(figure) == 1 + assert _count_rug_marker_lines(figure) == 0 + + +def test_that_all_three_components_render_when_all_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=True, gkde_plot=True, rug_plot=True + ) + + figure = _plot(context, varying_data_map) + + assert _count_histogram_bars(figure) > 0 + assert _count_kde_lines(figure) == 1 + assert _count_rug_marker_lines(figure) == 1 + + +def test_that_only_rug_axes_are_created_when_only_rug_selected( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=False, rug_plot=True + ) + + figure = _plot(context, varying_data_map) + + # One rug axis per ensemble and no separate main plot on top. + assert len(figure.axes) == 1 + + +def test_that_one_rug_axis_is_created_per_ensemble_for_two_ensembles() -> None: + ensembles = [_make_ensemble("ensemble_1"), _make_ensemble("ensemble_2")] + data_map = { + ensembles[0]: pd.DataFrame({0: [0.1, 0.2, 0.3, 0.4]}), + ensembles[1]: pd.DataFrame({0: [0.5, 0.6, 0.7, 0.8]}), + } + context = _make_context(ensembles, histogram=False, gkde_plot=False, rug_plot=True) + + figure = _plot(context, data_map) + + assert len(figure.axes) == 2 + assert _count_rug_marker_lines(figure) == 2 + + +def test_that_histogram_count_uses_twin_axis_when_gkde( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], + histogram=True, + gkde_plot=True, + rug_plot=False, + ) + + figure = _plot(context, varying_data_map) + + # A twin y-axis adds a second axes sharing the same subplot position. + assert len(figure.axes) == 2 + y_labels = {axes.get_ylabel() for axes in figure.axes} + assert "Count (Histogram)" in y_labels + assert "Density (Gaussian KDE)" in y_labels + + +def test_that_histogram_uses_log_x_scale_when_log_scale_enabled( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], + histogram=True, + gkde_plot=False, + rug_plot=False, + log_scale=True, + ) + + figure = _plot(context, varying_data_map) + + assert figure.axes[0].get_xscale() == "log" + + +def test_that_gkde_line_is_not_drawn_for_constant_data( + single_ensemble: EnsembleObject, +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=True, rug_plot=False + ) + data_map = {single_ensemble: pd.DataFrame({0: [1.0, 1.0, 1.0, 1.0]})} + + figure = _plot(context, data_map) + + assert _count_kde_lines(figure) == 0 + + +def test_that_gkde_line_is_not_drawn_for_categorical_data( + single_ensemble: EnsembleObject, +) -> None: + context = _make_context( + [single_ensemble], histogram=False, gkde_plot=True, rug_plot=False + ) + data_map = {single_ensemble: pd.DataFrame({0: ["cat", "dog", "fish"]})} + + figure = _plot(context, data_map) + + assert _count_kde_lines(figure) == 0 + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + pytest.param(pd.DataFrame({0: []}), True, id="empty"), + pytest.param(pd.DataFrame({0: [3.0, 3.0, 3.0]}), True, id="constant"), + pytest.param(pd.DataFrame({0: [1.0, 2.0, 3.0]}), False, id="varying"), + ], +) +def test_that_array_is_constant_detects_empty_constant_and_varying( + data: pd.DataFrame, expected: bool +) -> None: + assert bool(_array_is_constant(data[0])) is expected + + +def test_that_rug_only_plot_uses_log_x_scale_when_log_scale_enabled( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], + histogram=False, + gkde_plot=False, + rug_plot=True, + log_scale=True, + ) + + figure = _plot(context, varying_data_map) + + assert all(axes.get_xscale() == "log" for axes in figure.axes) + + +def test_that_all_plots_uses_log_x_scale_when_log_scale_enabled( + single_ensemble: EnsembleObject, + varying_data_map: dict[EnsembleObject, pd.DataFrame], +) -> None: + context = _make_context( + [single_ensemble], + histogram=True, + gkde_plot=True, + rug_plot=True, + log_scale=True, + ) + + figure = _plot(context, varying_data_map) + + assert all(axes.get_xscale() == "log" for axes in figure.axes) + + +def test_that_the_plot_skips_categorical_data_without_raising_error( + single_ensemble: EnsembleObject, +): + + categorical_df = pd.DataFrame({0: ["cat", "dog", "fish"], 1: [12, 12, 12]}) + + context = PlotContext( + PlotConfig(), + ensembles=[single_ensemble], + ensembles_color_indexes=[0], + key="animal_type", + layer=None, + ) + figure = _plot(context, {single_ensemble: categorical_df}) + + assert _count_kde_lines(figure) == 0 + assert _count_histogram_bars(figure) == 0 + assert _count_rug_marker_lines(figure) == 0 diff --git a/tests/ert/unit_tests/gui/plotting/widgets/test_distribution_options.py b/tests/ert/unit_tests/gui/plotting/widgets/test_distribution_options.py new file mode 100644 index 00000000000..4573e059e0a --- /dev/null +++ b/tests/ert/unit_tests/gui/plotting/widgets/test_distribution_options.py @@ -0,0 +1,83 @@ +import logging +from unittest.mock import Mock + +import pytest +from PyQt6.QtWidgets import QCheckBox + +from ert.gui.plotting.widgets.plot_controls.distribution_options import ( + NAME_AND_TOOLTIP, + DistributionOptions, +) + + +def find_and_click_checkbox(widget, obj_name, qtbot, qt_type: type[QCheckBox]): + child = widget.findChild(qt_type, obj_name) + assert child is not None + child.click() + + +def test_that_all_distribution_options_are_enabled_by_default(qtbot): + options = DistributionOptions(Mock()) + widget = options.get_widget() + qtbot.addWidget(widget) + widget.show() + + assert options.histogram_checkbox_state is True + assert options.gkde_checkbox_state is True + assert options.rug_checkbox_state is True + + +@pytest.mark.parametrize( + ("checkbox_name", "state_attr", "index"), + [ + (NAME_AND_TOOLTIP[0][0], "histogram_checkbox_state", 0), + (NAME_AND_TOOLTIP[1][0], "gkde_checkbox_state", 1), + (NAME_AND_TOOLTIP[2][0], "rug_checkbox_state", 2), + ], +) +def test_that_unchecking_a_distribution_option_updates_state_and_notifies_and_logs( + qtbot, caplog, checkbox_name, state_attr, index +): + caplog.set_level( + logging.INFO, + logger="ert.gui.plotting.widgets.plot_controls.distribution_options", + ) + connection_point = Mock() + options = DistributionOptions(connection_point) + widget = options.get_widget() + qtbot.addWidget(widget) + widget.show() + + find_and_click_checkbox(widget, checkbox_name, qtbot, QCheckBox) + + assert widget.findChild(QCheckBox, checkbox_name).isChecked() is False + assert getattr(options, state_attr) is False + connection_point.assert_called_once() + assert ( + f"Plot sidebar option used: 'Distribution option: {NAME_AND_TOOLTIP[index][1]}'" + in caplog.text + ) + + +def test_that_a_distribution_toggle_is_logged_only_once_per_session(qtbot, caplog): + caplog.set_level( + logging.INFO, + logger="ert.gui.plotting.widgets.plot_controls.distribution_options", + ) + + options = DistributionOptions(Mock()) + widget = options.get_widget() + qtbot.addWidget(widget) + widget.show() + + find_and_click_checkbox(widget, NAME_AND_TOOLTIP[1][0], qtbot, QCheckBox) + find_and_click_checkbox(widget, NAME_AND_TOOLTIP[1][0], qtbot, QCheckBox) + find_and_click_checkbox(widget, NAME_AND_TOOLTIP[1][0], qtbot, QCheckBox) + + gkde_logs = [ + r.message + for r in caplog.records + if r.message + == f"Plot sidebar option used: 'Distribution option: {NAME_AND_TOOLTIP[1][1]}'" + ] + assert len(gkde_logs) == 1 From 4bb34ff07174236d27c0c6ccfae7b638944cea6e Mon Sep 17 00:00:00 2001 From: Eilert Skram Date: Tue, 14 Jul 2026 11:54:25 +0200 Subject: [PATCH 2/4] Update PlotTools methods Prep. for new DistributionPlot The DistributionPlot will not be able to use finalizePlot(), due to it having multiple axis and grid. Want to allow users to modify the figure, e.g changing labels, toggle grid etc. Extracting methods from finalizePlot() to standalone methods, such that implementation can try to align as much as possible with the finalizePlot()-flow --- src/ert/gui/plotting/utils/plot_tools.py | 29 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/ert/gui/plotting/utils/plot_tools.py b/src/ert/gui/plotting/utils/plot_tools.py index fe8ea17ce73..a31104ba087 100644 --- a/src/ert/gui/plotting/utils/plot_tools.py +++ b/src/ert/gui/plotting/utils/plot_tools.py @@ -95,19 +95,23 @@ def finalize_plot( PlotTools._setup_labels(plot_context, default_x_label, default_y_label) - plot_config = plot_context.plotConfig() - axes.set_xlabel(plot_config.x_label()) # type: ignore - axes.set_ylabel(plot_config.y_label()) # type: ignore + PlotTools.set_labels_for_axes_from_context(axes, plot_context) + axes.set_xlim(auto=False) axes.set_ylim(auto=False) - axes.set_title(plot_config.title()) + PlotTools.set_title(axes, plot_context) if plot_context.is_date_support_active(): figure.autofmt_xdate() - for spine in ("right", "left", "top"): - axes.spines[spine].set_visible(False) + PlotTools.remove_spines(axes, ["right", "left", "top"]) + + @staticmethod + def set_title(axes: Axes, plot_context: PlotContext) -> None: + title = plot_context.plotConfig().title() + if title is not None: + axes.set_title(title) @staticmethod def _setup_labels( @@ -180,3 +184,16 @@ def _handle_event(event: Event) -> None: "motion_notify_event", _handle_event, ) + + @staticmethod + def set_labels_for_axes_from_context(axes: Axes, plot_context: PlotContext) -> None: + config = plot_context.plotConfig() + if (x_label := config.x_label()) is not None: + axes.set_xlabel(x_label) + if (y_label := config.y_label()) is not None: + axes.set_ylabel(y_label) + + @staticmethod + def remove_spines(axes: Axes, spines_to_remove: list[str]) -> None: + for spine in spines_to_remove: + axes.spines[spine].set_visible(False) From 32c1c5b4b222e60addd25bc7266781784506529b Mon Sep 17 00:00:00 2001 From: Eilert Skram Date: Tue, 14 Jul 2026 11:57:36 +0200 Subject: [PATCH 3/4] Change layout engine and reset all axes Prep. for new DistributionPlot The new DistributionPlot will use multiple axis, so need to be able to reset all axes at reset(). Otherwise will get log-scale error when un-checking the log-scale checkbox. --- src/ert/gui/plotting/widgets/plot_widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ert/gui/plotting/widgets/plot_widget.py b/src/ert/gui/plotting/widgets/plot_widget.py index 6daa4029d57..fbb4e29032e 100644 --- a/src/ert/gui/plotting/widgets/plot_widget.py +++ b/src/ert/gui/plotting/widgets/plot_widget.py @@ -151,6 +151,11 @@ def __init__( self.resetPlot() def resetPlot(self) -> None: + # Some figures contain twinaxes + # Resetting the xscale to linear for all axes + # to avoid log scale issues when re-plotting after a log scale plot + for ax in self._figure.axes: + ax.set_xscale("linear") self._figure.clear() @property From 01a038f995b82b86ceebd18d9f4b9ffa84fc0e51 Mon Sep 17 00:00:00 2001 From: Eilert Skram Date: Tue, 14 Jul 2026 12:00:07 +0200 Subject: [PATCH 4/4] Add DistributionPlot and remove GKDE-plot DistributionPlot implementation Removed GKDE, including references in plot_window-tests Added DistributionPlot and corresponding tests. The plot implements three methods of plotting the distribution: - Gaussian KDE - Overlapping Histogram (y-axis can be set to count or density) - Rug plots for to visualize the raw datapoints. Behaviour of the plot changes depending of what plots are toggled. If GKDE or Hist. is turned off, the rugplots will act as the main window otherwise, they will be a supporting plot underneath the "main" plot-window If multiple ensembles are selected the overlapping histograms can become muddied and hard to distinquish. This is not a common use-case as most users will only select first and last. No screenshot-test added, only unit testing. Plot will likely undergo refactoring/updates, so will await screenshot-testing until "stable". Note: Cannot remove original HistogramPlot as of now, as the new plot can not handle categorical data yet. --- src/ert/gui/plotting/ert_plots/__init__.py | 2 - .../gui/plotting/ert_plots/distribution.py | 432 ++++++++++++++---- .../gui/plotting/ert_plots/gaussian_kde.py | 106 ----- src/ert/gui/plotting/plot_window.py | 5 +- src/ert/gui/plotting/utils/plot_maps.py | 2 - tests/ert/ui_tests/gui/test_main_window.py | 1 - .../gui/test_plotting_of_snake_oil.py | 2 - .../ert_plots/test_distribution_plot.py | 167 ++----- .../gui/tools/plot/test_plot_window.py | 33 +- 9 files changed, 388 insertions(+), 362 deletions(-) delete mode 100644 src/ert/gui/plotting/ert_plots/gaussian_kde.py diff --git a/src/ert/gui/plotting/ert_plots/__init__.py b/src/ert/gui/plotting/ert_plots/__init__.py index 4f09f7cd292..47c75f4b5f6 100644 --- a/src/ert/gui/plotting/ert_plots/__init__.py +++ b/src/ert/gui/plotting/ert_plots/__init__.py @@ -1,6 +1,5 @@ from .cesp import CrossEnsembleStatisticsPlot from .distribution import DistributionPlot -from .gaussian_kde import GaussianKDEPlot from .histogram import HistogramPlot from .misfits import MisfitsPlot from .statistics import StatisticsPlot @@ -9,7 +8,6 @@ __all__ = [ "CrossEnsembleStatisticsPlot", "DistributionPlot", - "GaussianKDEPlot", "HistogramPlot", "MisfitsPlot", "StatisticsPlot", diff --git a/src/ert/gui/plotting/ert_plots/distribution.py b/src/ert/gui/plotting/ert_plots/distribution.py index 533fbd57d17..5a74ba35cc8 100644 --- a/src/ert/gui/plotting/ert_plots/distribution.py +++ b/src/ert/gui/plotting/ert_plots/distribution.py @@ -1,11 +1,16 @@ from __future__ import annotations +import math +from collections.abc import Sequence from typing import TYPE_CHECKING import numpy as np import pandas as pd +from matplotlib.lines import Line2D +from scipy.stats import gaussian_kde from ert.gui.plotting.plot_api import EnsembleObject, PlotApiKeyDefinition +from ert.gui.plotting.utils.plot_context import PlotType from ert.gui.plotting.utils.plot_tools import ConditionalAxisFormatter, PlotTools from ert.gui.utils import truncate_experiment_name @@ -13,18 +18,25 @@ import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure + from matplotlib.gridspec import GridSpec from ert.gui.plotting.utils import PlotConfig, PlotContext from ert.gui.plotting.utils.plot_types import ObservationPlotLocations +MAIN_PLOT_HEIGHT_RATIO = 6 +RUG_PLOT_HEIGHT_RATIO = 0.5 +DEFAULT_HISTOGRAM_LABEL = "Count (Histogram)" +DEFAULT_GKDE_LABEL = "Estimated density (Lines)" + + class DistributionPlot: def __init__(self) -> None: self.dimensionality = 1 self.requires_observations = False - @staticmethod def plot( + self, figure: Figure, plot_context: PlotContext, ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], @@ -33,109 +45,339 @@ def plot( obs_loc: ObservationPlotLocations | None, key_def: PlotApiKeyDefinition | None = None, ) -> None: - plotDistribution(figure, plot_context, ensemble_to_data_map, observation_data) + self._rug_plot = plot_context.rug_plot + self._histogram = plot_context.histogram + self._gkde_plot = plot_context.gkde_plot + if not self._histogram and not self._gkde_plot and not self._rug_plot: + figure.text( + 0.5, + 0.5, + ( + "No plot options selected." + "\n\nFrom the Distribution options (on the right-side panel)," + "\nplease select at least one of the following:" + "\n- Histogram" + "\n- Estimated density" + "\n- Individual points" + "\n\nHover over the options for more information." + ), + ha="center", + va="center", + fontsize=12, + ) + return -def plotDistribution( - figure: Figure, - plot_context: PlotContext, - ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], - _observation_data: pd.DataFrame, -) -> None: - config = plot_context.plotConfig() - axes = figure.add_subplot(111) - - plot_context.deactivate_date_support() - - plot_context.y_axis = plot_context.VALUE_AXIS - - ensemble_list = plot_context.ensembles() - ensemble_indexes: list[int] = [] - previous_data = None - for (ensemble_index, (ensemble, data)), color_index in zip( - enumerate(ensemble_to_data_map.items()), - plot_context.ensembles_color_indexes(), - strict=False, - ): - config.set_current_color(color_index) - ensemble_indexes.append(ensemble_index) - - if not data.empty: - _plotDistribution( - axes, config, data, ensemble.name, ensemble_index, previous_data + if not self._histogram and not self._gkde_plot: + # Only rug plots, no empty main plot on top + self._plot_rug( + figure, + plot_context, + ensemble_to_data_map, + number_of_ensembles=len(plot_context.ensembles()), + gridspec=None, + main_plot=None, ) + return + + self._plot_distribution(figure, plot_context, ensemble_to_data_map) + + def _plot_distribution( + self, + figure: Figure, + plot_context: PlotContext, + ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], + ) -> None: + config = plot_context.plotConfig() + number_of_ensembles = len(plot_context.ensembles()) - previous_data = data + main_axes = self._create_main_axes( + figure, plot_context, ensemble_to_data_map, number_of_ensembles + ) + # Histogram uses separate y-axis ontop of + # the density y-axis of the Gaussian KDE + # to display count, rather than density. + use_twin_axes = self._gkde_plot and self._histogram + histogram_axes = main_axes.twinx() if use_twin_axes else main_axes - axes.set_xticks([-1, *ensemble_indexes, len(ensemble_indexes)]) + plot_context.x_axis = plot_context.VALUE_AXIS - rotation = 0 - if len(ensemble_list) > 3: - rotation = 30 + for (ensemble, data), color_index in zip( + ensemble_to_data_map.items(), + plot_context.ensembles_color_indexes(), + strict=False, + ): + config.set_current_color(color_index) + if self._gkde_plot: + self._plot_gkde( + main_axes, data[0], config, log_scale=plot_context.log_scale + ) - axes.set_xticklabels( - [""] - + [ - f"{truncate_experiment_name(ensemble.experiment_name)} : {ensemble.name}" - for ensemble in ensemble_list - ] - + [""], - rotation=rotation, - ) - config.set_legend_enabled(False) - - if plot_context.log_scale: - axes.set_yscale("log") - - PlotTools.finalize_plot( - plot_context, figure, axes, default_x_label="Ensemble", default_y_label="Value" - ) - - -def _plotDistribution( - axes: Axes, - plot_config: PlotConfig, - data: pd.DataFrame, - label: str, - index: int, - previous_data: pd.DataFrame | None, -) -> None: - data = pd.Series(dtype="float64") if data.empty else data[0] - - axes.yaxis.set_major_formatter(ConditionalAxisFormatter()) - axes.set_xlabel(plot_config.x_label()) # type: ignore - axes.set_ylabel(plot_config.y_label()) # type: ignore - - style = plot_config.distribution_style() - - if not pd.api.types.is_numeric_dtype(data): - data = pd.to_numeric(data, errors="coerce") - - if not pd.api.types.is_numeric_dtype(data): - dots = [] - else: - dots = axes.plot( - [index] * len(data), + if self._histogram: + self._plot_histogram(data[0], plot_context, histogram_axes) + + self._add_ensemble_legend(config, ensemble) + + if self._gkde_plot: + main_axes.set_ylim(bottom=0) + if use_twin_axes: + self._scale_count_axes(main_axes, histogram_axes) + + self._finalize_axes(main_axes, histogram_axes, plot_context) + + def _create_main_axes( + self, + figure: Figure, + plot_context: PlotContext, + ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], + number_of_ensembles: int, + ) -> Axes: + if self._rug_plot: + gridspec = figure.add_gridspec( + number_of_ensembles + 1, + 1, + height_ratios=[ + MAIN_PLOT_HEIGHT_RATIO, + *([RUG_PLOT_HEIGHT_RATIO] * number_of_ensembles), + ], + hspace=0.02, + ) + main_axes = figure.add_subplot(gridspec[0]) + self._plot_rug( + figure, + plot_context, + ensemble_to_data_map, + number_of_ensembles, + gridspec, + main_axes, + ) + else: + main_axes = figure.add_subplot(111) + return main_axes + + @staticmethod + def _evaluate_kde( + data: pd.Series, + log_scale: bool, + ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + sample_range = data.max() - data.min() + lower_bound = data.min() - 0.5 * sample_range + upper_bound = data.max() + 0.5 * sample_range + indexes = np.linspace( + lower_bound if not log_scale else max(lower_bound, 1e-10), + upper_bound, + 1000, + ) + gkde = gaussian_kde(data.values) + return indexes, gkde.evaluate(indexes) + + def _scale_count_axes( + self, gkde_axes: Axes, histogram_axes: Axes, n_ticks: int = 6 + ) -> None: + histogram_axes.set_ylim(bottom=0) + + count_max = histogram_axes.get_ylim()[1] + n_steps = math.ceil(count_max / (n_ticks - 1)) + top_int = n_steps * (n_ticks - 1) + histogram_axes.set_ylim(0, top_int) + histogram_axes.set_yticks(np.arange(0, top_int + 1, n_steps)) + + gkde_axes.set_yticks(np.linspace(*gkde_axes.get_ylim(), n_ticks)) + gkde_axes.yaxis.set_major_formatter(ConditionalAxisFormatter()) + + def _finalize_axes( + self, + main_axes: Axes, + histogram_axes: Axes, + plot_context: PlotContext, + ) -> None: + if self._gkde_plot: + self._add_gkde_legend(plot_context.plotConfig()) + main_axes.set_xlabel(plot_context.plotConfig().x_label() or "Value") + if self._gkde_plot: + histogram_axes.set_ylabel(DEFAULT_HISTOGRAM_LABEL) + main_axes.set_ylabel(DEFAULT_GKDE_LABEL) + else: + set_ylabel_by_config(plot_context, main_axes, "Count") + + plot_context.plot_type = PlotType.BAR + + PlotTools.set_title(main_axes, plot_context) + + axes_to_clean = [main_axes, histogram_axes] + for axes in axes_to_clean: + PlotTools.remove_spines(axes, ["right", "left", "top"]) + + PlotTools.show_grid(main_axes, plot_context) + PlotTools.show_legend(main_axes, plot_context) + + def _plot_gkde( + self, axes: Axes, data: pd.Series, config: PlotConfig, log_scale: bool + ) -> None: + if _array_is_empty_or_non_numeric(data) or _array_is_constant(data): + return + indexes, evaluated = self._evaluate_kde(data, log_scale=log_scale) + if log_scale: + axes.set_xscale("log") + axes.plot(indexes, evaluated, color=config.current_color()) + + def _plot_histogram( + self, + data: pd.Series, + plot_context: PlotContext, + histogram_axes: Axes, + ) -> None: + if _array_is_empty_or_non_numeric(data): + return + + config = plot_context.plotConfig() + bins: str | Sequence[float] + if plot_context.log_scale: + log_edges = np.histogram_bin_edges(np.log10(data), bins="sqrt") + edges = 10**log_edges + # 10 ** log10(x) may not round-trip, which would drop the extreme values + edges[0] = min(edges[0], data.min()) + edges[-1] = max(edges[-1], data.max()) + bins = edges.tolist() + histogram_axes.set_xscale("log") + else: + bins = "sqrt" + histogram_axes.set_xscale("linear") + histogram_axes.xaxis.set_major_formatter(ConditionalAxisFormatter()) + + histogram_axes.hist( data, - color=style.color, - alpha=style.alpha, - marker=style.marker, - linestyle=style.line_style, - markersize=style.size, + bins=bins, + alpha=0.3, + color=config.current_color(), ) - if plot_config.is_distribution_line_enabled() and previous_data is not None: - line_style = plot_config.distributionLineStyle() - x = [index - 1, index] - y = [previous_data[0], data] - axes.plot( - x, - y, - color=line_style.color, - alpha=line_style.alpha, - linestyle=line_style.line_style, - linewidth=line_style.width, + def _plot_rug( + self, + figure: Figure, + plot_context: PlotContext, + ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], + number_of_ensembles: int, + gridspec: GridSpec | None = None, + main_plot: Axes | None = None, + ) -> None: + config = plot_context.plotConfig() + only_rug = main_plot is None + + if only_rug: + # Adding two padding rows + # Constrained layout engine will otherwise spread + # the rug plots out too much + gridspec = figure.add_gridspec( + number_of_ensembles + 2, + 1, + height_ratios=[3, *([RUG_PLOT_HEIGHT_RATIO] * number_of_ensembles), 3], + ) + + # In only_rug mode rugs share x with each other, otherwise with main_plot + share_ref = main_plot + rug_plots: list[Axes] = [] + for i in range(number_of_ensembles): + row_index = i + 1 + axes = figure.add_subplot(gridspec[row_index], sharex=share_ref) # type: ignore + share_ref = share_ref or axes + rug_plots.append(axes) + + for index, ((ensemble, data), color_index) in enumerate( + zip( + ensemble_to_data_map.items(), + plot_context.ensembles_color_indexes(), + strict=False, + ) + ): + if _array_is_empty_or_non_numeric(data[0]): + continue + + rug = rug_plots[index] + config.set_current_color(color_index) + rug.plot( + data[0], + np.zeros(len(data[0])), + marker="|", + markersize=15, + linestyle="", + color=config.current_color(), + ) + rug.axhline( + y=0, + color="grey", + linewidth=0.8, ) + if only_rug: + self._add_ensemble_legend(config, ensemble) + + rug.yaxis.set_visible(False) + rug.xaxis.set_visible( + only_rug and index == number_of_ensembles - 1 + ) # x-axis on all if no mainplot, otherwise only on last rug plot + if only_rug and index == number_of_ensembles - 1: + rug.tick_params(axis="x", labelbottom=True) + PlotTools.remove_spines(rug, ["top", "right", "left", "bottom"]) + if plot_context.log_scale: + rug.set_xscale("log") + + if only_rug: + PlotTools.set_title(rug_plots[0], plot_context) + if config.is_legend_enabled() and config.legend_items(): + figure.legend( + config.legend_items(), + config.legend_labels(), + numpoints=1, + loc="lower center", + ncols=min(len(config.legend_items()), 4), + frameon=False, + ) + + def _add_ensemble_legend( + self, config: PlotConfig, ensemble: EnsembleObject + ) -> None: + label = ( + f"{truncate_experiment_name(ensemble.experiment_name)} : {ensemble.name}" + ) + config.add_legend_item( + label, + Line2D( + [], + [], + marker="s", + linestyle="None", + color=config.current_color(), + label=label, + ), + ) + + def _add_gkde_legend(self, config: PlotConfig) -> None: + label = "Estimated density" + config.add_legend_item( + label, + Line2D( + [], + [], + linestyle="-", + color="grey", + label=label, + ), + ) + + +def set_ylabel_by_config(plot_context: PlotContext, axes: Axes, y_label: str) -> None: + config = plot_context.plotConfig() + if config.x_label() is None: + config.set_x_label("Value") + if config.y_label() is None: + config.set_y_label(y_label) + PlotTools.set_labels_for_axes_from_context(axes, plot_context) + + +def _array_is_constant(data: pd.Series | pd.DataFrame) -> bool: + array = data.to_numpy() + return array.shape[0] == 0 or (array[0] == array).all() + - if len(dots) > 0: - plot_config.add_legend_item(label, dots[0]) +def _array_is_empty_or_non_numeric(data: pd.Series | pd.DataFrame) -> bool: + return data.empty or not pd.api.types.is_numeric_dtype(data) diff --git a/src/ert/gui/plotting/ert_plots/gaussian_kde.py b/src/ert/gui/plotting/ert_plots/gaussian_kde.py deleted file mode 100644 index ae5d117b5d7..00000000000 --- a/src/ert/gui/plotting/ert_plots/gaussian_kde.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import numpy as np -import pandas as pd -from scipy.stats import gaussian_kde - -from ert.gui.plotting.plot_api import EnsembleObject, PlotApiKeyDefinition -from ert.gui.plotting.utils.plot_tools import ConditionalAxisFormatter, PlotTools -from ert.gui.utils import truncate_experiment_name - -if TYPE_CHECKING: - import numpy.typing as npt - from matplotlib.axes import Axes - from matplotlib.figure import Figure - - from ert.gui.plotting.utils import PlotConfig, PlotContext - from ert.gui.plotting.utils.plot_types import ObservationPlotLocations - - -class GaussianKDEPlot: - def __init__(self) -> None: - self.dimensionality = 1 - self.requires_observations = False - - @staticmethod - def plot( - figure: Figure, - plot_context: PlotContext, - ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], - observation_data: pd.DataFrame, - std_dev_images: dict[str, npt.NDArray[np.float32]], - obs_loc: ObservationPlotLocations | None, - key_def: PlotApiKeyDefinition | None = None, - ) -> None: - plotGaussianKDE(figure, plot_context, ensemble_to_data_map, observation_data) - - -def _array_is_constant(data: pd.Series | pd.DataFrame) -> bool: - array = data.to_numpy() - return array.shape[0] == 0 or (array[0] == array).all() - - -def plotGaussianKDE( - figure: Figure, - plot_context: PlotContext, - ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame], - _observation_data: Any, -) -> None: - config = plot_context.plotConfig() - axes = figure.add_subplot(111) - - plot_context.deactivate_date_support() - plot_context.x_axis = plot_context.VALUE_AXIS - plot_context.y_axis = plot_context.DENSITY_AXIS - - for (ensemble, data), color_index in zip( - ensemble_to_data_map.items(), - plot_context.ensembles_color_indexes(), - strict=False, - ): - config.set_current_color(color_index) - if data.empty or not pd.api.types.is_numeric_dtype(data[0]): - continue - if not _array_is_constant(data[0]): - _plotGaussianKDE( - axes, - config, - data[0], - f"{truncate_experiment_name(ensemble.experiment_name)}" - f" : {ensemble.name}", - ) - - if plot_context.log_scale: - axes.set_xscale("log") - - PlotTools.finalize_plot( - plot_context, figure, axes, default_x_label="Value", default_y_label="Density" - ) - - -def _plotGaussianKDE( - axes: Axes, plot_config: PlotConfig, data: pd.DataFrame, label: str -) -> None: - style = plot_config.histogram_style() - - sample_range = data.max() - data.min() - indexes = np.linspace( - data.min() - 0.5 * sample_range, data.max() + 0.5 * sample_range, 1000 - ) - gkde = gaussian_kde(data.values) - evaluated_gkde = gkde.evaluate(indexes) - - axes.xaxis.set_major_formatter(ConditionalAxisFormatter()) - - lines = axes.plot( - indexes, - evaluated_gkde, - linewidth=style.width, - color=style.color, - alpha=style.alpha, - ) - - if len(lines) > 0: - plot_config.add_legend_item(label, lines[0]) diff --git a/src/ert/gui/plotting/plot_window.py b/src/ert/gui/plotting/plot_window.py index c324cc93e70..7b6dc18e549 100644 --- a/src/ert/gui/plotting/plot_window.py +++ b/src/ert/gui/plotting/plot_window.py @@ -65,10 +65,10 @@ from .widgets.everest_control_selection_widget import EverestControlSelectionWidget from .widgets.plot_controls import ( BoxplotOptions, + DistributionOptions, EverestControlsPlotOptions, GeneralPlotOptions, StatisticsOptions, - DistributionOptions, ) from .widgets.plot_ensemble_selection_widget import EnsembleSelectionWidget from .widgets.plot_widget import Plotter, PlotWidget @@ -422,6 +422,9 @@ def update_plot(self, layer: int | None = None) -> None: ) self._statistics_options.get_widget().setVisible(plot_widget.name == STATISTICS) self._general_options.get_widget().setVisible(plot_widget.name != STD_DEV) + self._distribution_options.get_widget().setVisible( + plot_widget.name == DISTRIBUTION + ) is_gradient_plot = plot_widget.name == EVEREST_GRADIENTS_PLOT is_controls_plot = plot_widget.name == EVEREST_CONTROLS_PLOT diff --git a/src/ert/gui/plotting/utils/plot_maps.py b/src/ert/gui/plotting/utils/plot_maps.py index 8ee1deceac4..0dd3eab78d0 100644 --- a/src/ert/gui/plotting/utils/plot_maps.py +++ b/src/ert/gui/plotting/utils/plot_maps.py @@ -3,7 +3,6 @@ from ert.gui.plotting.ert_plots import ( CrossEnsembleStatisticsPlot, DistributionPlot, - GaussianKDEPlot, HistogramPlot, MisfitsPlot, StatisticsPlot, @@ -37,7 +36,6 @@ STATISTICS: StatisticsPlot, MISFITS: MisfitsPlot, HISTOGRAM: HistogramPlot, - GAUSSIAN_KDE: GaussianKDEPlot, DISTRIBUTION: DistributionPlot, CROSS_ENSEMBLE_STATISTICS: CrossEnsembleStatisticsPlot, STD_DEV: StdDevPlot, diff --git a/tests/ert/ui_tests/gui/test_main_window.py b/tests/ert/ui_tests/gui/test_main_window.py index e22435f033c..b8bc7ecc745 100644 --- a/tests/ert/ui_tests/gui/test_main_window.py +++ b/tests/ert/ui_tests/gui/test_main_window.py @@ -350,7 +350,6 @@ def test_that_the_plot_window_contains_the_expected_elements( } == { "Cross ensemble statistics", "Distribution", - "Gaussian KDE", "Ensemble", "Histogram", "Statistics", diff --git a/tests/ert/ui_tests/gui/test_plotting_of_snake_oil.py b/tests/ert/ui_tests/gui/test_plotting_of_snake_oil.py index 92ab2da415e..20f2fb755d5 100644 --- a/tests/ert/ui_tests/gui/test_plotting_of_snake_oil.py +++ b/tests/ert/ui_tests/gui/test_plotting_of_snake_oil.py @@ -13,7 +13,6 @@ CROSS_ENSEMBLE_STATISTICS, DISTRIBUTION, ENSEMBLE, - GAUSSIAN_KDE, HISTOGRAM, STATISTICS, STD_DEV, @@ -37,7 +36,6 @@ ("SNAKE_OIL_PARAM_OP1:OP1_OCTAVES", CROSS_ENSEMBLE_STATISTICS, "snake_oil"), ("COND", STD_DEV, "heat_equation"), ("SNAKE_OIL_PARAM_OP1:OP1_OCTAVES", DISTRIBUTION, "snake_oil"), - ("SNAKE_OIL_PARAM_OP1:OP1_OCTAVES", GAUSSIAN_KDE, "snake_oil"), ("SNAKE_OIL_PARAM_OP1:OP1_OCTAVES", HISTOGRAM, "snake_oil"), ("SNAKE_OIL_WPR_DIFF@199", ENSEMBLE, "snake_oil"), ], diff --git a/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py b/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py index 0abc22161e1..e22db5b8902 100644 --- a/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py +++ b/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py @@ -7,6 +7,8 @@ from matplotlib.figure import Figure from ert.gui.plotting.ert_plots.distribution import ( + DEFAULT_GKDE_LABEL, + DEFAULT_HISTOGRAM_LABEL, DistributionPlot, _array_is_constant, ) @@ -31,7 +33,6 @@ def _make_context( histogram: bool = True, gkde_plot: bool = True, rug_plot: bool = True, - by_density: bool = True, log_scale: bool = False, ) -> PlotContext: context = PlotContext( @@ -93,107 +94,58 @@ def varying_data_map( return {single_ensemble: pd.DataFrame({0: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})} -def test_that_distribution_plot_shows_message_when_no_plot_option_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=False, gkde_plot=False, rug_plot=False - ) - - figure = _plot(context, varying_data_map) - - assert any("No plot options selected." in text.get_text() for text in figure.texts) - assert figure.axes == [] - - -def test_that_only_histogram_is_rendered_when_only_histogram_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=True, gkde_plot=False, rug_plot=False - ) - - figure = _plot(context, varying_data_map) - - assert _count_histogram_bars(figure) > 0 - assert _count_kde_lines(figure) == 0 - assert _count_rug_marker_lines(figure) == 0 - - -def test_that_only_gkde_is_rendered_when_only_gkde_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=False, gkde_plot=True, rug_plot=False - ) - - figure = _plot(context, varying_data_map) - - assert _count_kde_lines(figure) == 1 - assert _count_histogram_bars(figure) == 0 - assert _count_rug_marker_lines(figure) == 0 - - -def test_that_only_rug_is_rendered_when_only_rug_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=False, gkde_plot=False, rug_plot=True - ) - - figure = _plot(context, varying_data_map) - - assert _count_rug_marker_lines(figure) == 1 - assert _count_histogram_bars(figure) == 0 - assert _count_kde_lines(figure) == 0 - - -def test_that_histogram_and_gkde_are_rendered_together_without_rug( +@pytest.mark.parametrize( + ("histogram", "gkde_plot", "rug_plot", "number_of_expected_axes"), + [ + pytest.param(False, False, False, 0, id="none"), + pytest.param(True, True, True, 3, id="all"), + pytest.param(True, True, False, 2, id="histogram_and_gkde"), + pytest.param(True, False, True, 2, id="histogram_and_rug"), + pytest.param(False, True, True, 2, id="gkde_and_rug"), + pytest.param(True, False, False, 1, id="histogram_only"), + pytest.param(False, True, False, 1, id="gkde_only"), + pytest.param(False, False, True, 1, id="rug_only"), + ], +) +def test_that_distribution_plot_renders_without_error_for_all_plot_option_combinations( single_ensemble: EnsembleObject, varying_data_map: dict[EnsembleObject, pd.DataFrame], + histogram: bool, + gkde_plot: bool, + rug_plot: bool, + number_of_expected_axes: int, ) -> None: context = _make_context( - [single_ensemble], histogram=True, gkde_plot=True, rug_plot=False + [single_ensemble], + histogram=histogram, + gkde_plot=gkde_plot, + rug_plot=rug_plot, ) figure = _plot(context, varying_data_map) - assert _count_histogram_bars(figure) > 0 - assert _count_kde_lines(figure) == 1 - assert _count_rug_marker_lines(figure) == 0 - + assert isinstance(figure, Figure) + assert len(figure.axes) == number_of_expected_axes -def test_that_all_three_components_render_when_all_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=True, gkde_plot=True, rug_plot=True + assert ( + _count_histogram_bars(figure) > 0 + if histogram + else _count_histogram_bars(figure) == 0 ) - - figure = _plot(context, varying_data_map) - - assert _count_histogram_bars(figure) > 0 - assert _count_kde_lines(figure) == 1 - assert _count_rug_marker_lines(figure) == 1 - - -def test_that_only_rug_axes_are_created_when_only_rug_selected( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], histogram=False, gkde_plot=False, rug_plot=True + assert _count_kde_lines(figure) > 0 if gkde_plot else _count_kde_lines(figure) == 0 + assert ( + _count_rug_marker_lines(figure) > 0 + if rug_plot + else _count_rug_marker_lines(figure) == 0 ) - - figure = _plot(context, varying_data_map) - - # One rug axis per ensemble and no separate main plot on top. - assert len(figure.axes) == 1 + if not histogram and not gkde_plot and not rug_plot: + assert any( + "No plot options selected." in text.get_text() for text in figure.texts + ) + if histogram and gkde_plot: + y_labels = {axes.get_ylabel() for axes in figure.axes} + assert DEFAULT_HISTOGRAM_LABEL in y_labels + assert DEFAULT_GKDE_LABEL in y_labels def test_that_one_rug_axis_is_created_per_ensemble_for_two_ensembles() -> None: @@ -210,26 +162,6 @@ def test_that_one_rug_axis_is_created_per_ensemble_for_two_ensembles() -> None: assert _count_rug_marker_lines(figure) == 2 -def test_that_histogram_count_uses_twin_axis_when_gkde( - single_ensemble: EnsembleObject, - varying_data_map: dict[EnsembleObject, pd.DataFrame], -) -> None: - context = _make_context( - [single_ensemble], - histogram=True, - gkde_plot=True, - rug_plot=False, - ) - - figure = _plot(context, varying_data_map) - - # A twin y-axis adds a second axes sharing the same subplot position. - assert len(figure.axes) == 2 - y_labels = {axes.get_ylabel() for axes in figure.axes} - assert "Count (Histogram)" in y_labels - assert "Density (Gaussian KDE)" in y_labels - - def test_that_histogram_uses_log_x_scale_when_log_scale_enabled( single_ensemble: EnsembleObject, varying_data_map: dict[EnsembleObject, pd.DataFrame], @@ -260,19 +192,6 @@ def test_that_gkde_line_is_not_drawn_for_constant_data( assert _count_kde_lines(figure) == 0 -def test_that_gkde_line_is_not_drawn_for_categorical_data( - single_ensemble: EnsembleObject, -) -> None: - context = _make_context( - [single_ensemble], histogram=False, gkde_plot=True, rug_plot=False - ) - data_map = {single_ensemble: pd.DataFrame({0: ["cat", "dog", "fish"]})} - - figure = _plot(context, data_map) - - assert _count_kde_lines(figure) == 0 - - @pytest.mark.parametrize( ("data", "expected"), [ diff --git a/tests/ert/unit_tests/gui/tools/plot/test_plot_window.py b/tests/ert/unit_tests/gui/tools/plot/test_plot_window.py index 3f0bbc9de41..61a4b16213c 100644 --- a/tests/ert/unit_tests/gui/tools/plot/test_plot_window.py +++ b/tests/ert/unit_tests/gui/tools/plot/test_plot_window.py @@ -20,7 +20,6 @@ from ert.config.breakthrough_config import BreakthroughConfig from ert.config.distribution import RawSettings from ert.config.gen_kw_config import DataSource, GenKwConfig -from ert.gui.plotting.ert_plots.gaussian_kde import plotGaussianKDE from ert.gui.plotting.ert_plots.histogram import HistogramPlot from ert.gui.plotting.models import DataTypeSeparator from ert.gui.plotting.plot_api import EnsembleObject, PlotApi, PlotApiKeyDefinition @@ -34,7 +33,6 @@ DISTRIBUTION, ENSEMBLE, ERT_PLOT_MAP, - GAUSSIAN_KDE, HISTOGRAM, STATISTICS, ) @@ -673,7 +671,7 @@ def test_that_log_scale_state_is_preserved_when_switching_plot_tabs( histogram_index = tab_index_by_name[HISTOGRAM] assert plot_window._central_tab.isTabEnabled(histogram_index) - log_scale_tabs = {HISTOGRAM, DISTRIBUTION, GAUSSIAN_KDE} + log_scale_tabs = {HISTOGRAM, DISTRIBUTION} non_log_scale_index = next( index for index in range(plot_window._central_tab.count()) @@ -792,15 +790,15 @@ def test_that_plot_tab_last_used_for_a_data_type_is_restored_when_returning_to_i _select_data_type_key(plot_window, "gen_kw") plot_window._central_tab.setCurrentWidget( - plot_window._find_widget_by_name(GAUSSIAN_KDE) + plot_window._find_widget_by_name(HISTOGRAM) ) _select_data_type_key(plot_window, "POLY_RES") _select_data_type_key(plot_window, "gen_kw") - assert _current_tab_name(plot_window) == GAUSSIAN_KDE + assert _current_tab_name(plot_window) == HISTOGRAM -@pytest.mark.parametrize("tab_name", [HISTOGRAM, DISTRIBUTION, GAUSSIAN_KDE]) +@pytest.mark.parametrize("tab_name", [HISTOGRAM, DISTRIBUTION]) @pytest.mark.parametrize( ("values", "expected_visible"), [ @@ -943,29 +941,6 @@ def mixed_dtype_data_for_parameter( plot_window.update_plot() -def test_that_gaussian_kde_plot_skips_categorical_data_without_raising(): - ensemble = EnsembleObject( - "ensemble", - "ensemble", - False, - "experiment", - "2026-01-01T00:00:00", - ) - categorical_df = pd.DataFrame({0: ["cat", "dog", "fish"], 1: [12, 12, 12]}) - - fig = Figure() - ctx = PlotContext( - PlotConfig(), - ensembles=[ensemble], - ensembles_color_indexes=[0], - key="animal_type", - layer=None, - ) - - # Should not raise (categorical data is simply skipped). - plotGaussianKDE(fig, ctx, {ensemble: categorical_df}, _observation_data=None) - - @pytest.mark.parametrize( "axis", [