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 c80b1a7c995..7b6dc18e549 100644 --- a/src/ert/gui/plotting/plot_window.py +++ b/src/ert/gui/plotting/plot_window.py @@ -65,6 +65,7 @@ from .widgets.everest_control_selection_widget import EverestControlSelectionWidget from .widgets.plot_controls import ( BoxplotOptions, + DistributionOptions, EverestControlsPlotOptions, GeneralPlotOptions, StatisticsOptions, @@ -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) @@ -418,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 @@ -596,6 +603,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/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/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) 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/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 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 new file mode 100644 index 00000000000..e22db5b8902 --- /dev/null +++ b/tests/ert/unit_tests/gui/plotting/ert_plots/test_distribution_plot.py @@ -0,0 +1,260 @@ +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 ( + DEFAULT_GKDE_LABEL, + DEFAULT_HISTOGRAM_LABEL, + 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, + 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]})} + + +@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=histogram, + gkde_plot=gkde_plot, + rug_plot=rug_plot, + ) + + figure = _plot(context, varying_data_map) + + assert isinstance(figure, Figure) + assert len(figure.axes) == number_of_expected_axes + + assert ( + _count_histogram_bars(figure) > 0 + if histogram + else _count_histogram_bars(figure) == 0 + ) + 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 + ) + 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: + 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_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 + + +@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 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", [