diff --git a/src/ert/gui/plotting/ert_plots/__init__.py b/src/ert/gui/plotting/ert_plots/__init__.py index 4f09f7cd292..e43d4c7d3bf 100644 --- a/src/ert/gui/plotting/ert_plots/__init__.py +++ b/src/ert/gui/plotting/ert_plots/__init__.py @@ -2,6 +2,7 @@ from .distribution import DistributionPlot from .gaussian_kde import GaussianKDEPlot from .histogram import HistogramPlot +from .misfit_map import MisfitMapPlot from .misfits import MisfitsPlot from .statistics import StatisticsPlot from .std_dev import StdDevPlot @@ -11,6 +12,7 @@ "DistributionPlot", "GaussianKDEPlot", "HistogramPlot", + "MisfitMapPlot", "MisfitsPlot", "StatisticsPlot", "StdDevPlot", diff --git a/src/ert/gui/plotting/ert_plots/misfit_map.py b/src/ert/gui/plotting/ert_plots/misfit_map.py new file mode 100644 index 00000000000..d0bf2e3e2f4 --- /dev/null +++ b/src/ert/gui/plotting/ert_plots/misfit_map.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import matplotlib.colors as mcolors +import numpy as np +import numpy.typing as npt +import pandas as pd +import polars as pl +from matplotlib.figure import Figure + +from ert.gui.plotting.ert_plots.misfits import MisfitsPlot + +if TYPE_CHECKING: + from ert.gui.plotting.plot_api import EnsembleObject, PlotApiKeyDefinition + from ert.gui.plotting.utils import PlotContext + from ert.gui.plotting.utils.plot_types import ObservationPlotLocations + + +class MisfitMapPlot: + def __init__(self) -> None: + self.dimensionality = 2 + self.requires_observations = True + self._misfit_range_cache: dict[str, float] = {} + + @staticmethod + def _show_no_data(figure: Figure, message: str) -> None: + axes = figure.add_subplot(111) + axes.text(0.5, 0.5, message, ha="center", va="center") + axes.set_axis_off() + + def plot( + self, + 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: + + if not ensemble_to_data_map: + self._show_no_data(figure, "No ensemble data available") + return + + first_ensemble, first_data = next(iter(ensemble_to_data_map.items())) + misfits_by_realization = MisfitsPlot._wide_pandas_to_long_polars_with_misfits( + {(first_ensemble.name, first_ensemble.id): first_data}, + observation_data, + "seismic", + )[first_ensemble.name, first_ensemble.id] + + if misfits_by_realization.is_empty(): + self._show_no_data(figure, "No misfit data available") + return + + mean_misfits = misfits_by_realization.group_by(["EAST", "NORTH"]).agg( + pl.col("misfit").mean() + ) + east = mean_misfits["EAST"].to_numpy() + north = mean_misfits["NORTH"].to_numpy() + misfit_values = mean_misfits["misfit"].to_numpy() + axes_misfit = figure.add_subplot(111) + key = key_def.key if key_def is not None else "" + current_vabs = ( + float(np.max(np.abs(misfit_values))) if misfit_values.size else 0.0 + ) + vabs = max(self._misfit_range_cache.get(key, 0.0), current_vabs) + if math.isclose(vabs, 0.0): + vabs = 1.0 + self._misfit_range_cache[key] = vabs + + norm = mcolors.Normalize(vmin=-vabs, vmax=vabs) + misfit_tripcolor = axes_misfit.tripcolor( + east, north, misfit_values, shading="flat", cmap="viridis", norm=norm + ) + + cbar = figure.colorbar( + misfit_tripcolor, + ax=axes_misfit, + label="Mean signed χ²", + orientation="vertical", + pad=0.15, + aspect=40, + ) + + cbar.ax.set_visible(plot_context.plotConfig().is_legend_enabled()) + config = plot_context.plotConfig() + axes_misfit.spines["top"].set_visible(False) + axes_misfit.spines["right"].set_visible(False) + axes_misfit.spines["left"].set_visible(False) + axes_misfit.spines["bottom"].set_visible(False) + axes_misfit.set_title(config.title() or "Misfit map") + axes_misfit.ticklabel_format(useOffset=False, style="plain") + axes_misfit.set_aspect("equal") + axes_misfit.set_xlabel(config.x_label() or "east coordinate") + axes_misfit.set_ylabel(config.y_label() or "north coordinate") + axes_misfit.grid(config.is_grid_enabled()) + axes_misfit.set_xlim(east.min(), east.max()) + axes_misfit.set_ylim(north.min(), north.max()) diff --git a/src/ert/gui/plotting/ert_plots/observations_map.py b/src/ert/gui/plotting/ert_plots/observations_map.py new file mode 100644 index 00000000000..9d8bd404c9d --- /dev/null +++ b/src/ert/gui/plotting/ert_plots/observations_map.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt +import pandas as pd +import polars as pl +from matplotlib.figure import Figure + +if TYPE_CHECKING: + from ert.gui.plotting.plot_api import EnsembleObject, PlotApiKeyDefinition + from ert.gui.plotting.utils import PlotContext + from ert.gui.plotting.utils.plot_types import ObservationPlotLocations + + +class ObservationsMapPlot: + def __init__(self) -> None: + self.dimensionality = 2 + self.requires_observations = True + + @staticmethod + def _show_no_data(figure: Figure, message: str) -> None: + axes = figure.add_subplot(111) + axes.text(0.5, 0.5, message, ha="center", va="center") + axes.set_axis_off() + + def plot( + self, + 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: + if observation_data.empty: + self._show_no_data(figure, "No observation data available") + return + + observation = pl.from_pandas(observation_data.T).rename( + {"EAST": "EAST", "NORTH": "NORTH", "OBS": "OBS"} + ) + + east = observation.get_column("EAST").to_numpy() + north = observation.get_column("NORTH").to_numpy() + observation_values = observation.get_column("OBS").to_numpy() + + axes = figure.add_subplot(111) + observation_tripcolor = axes.tripcolor( + east, north, observation_values, shading="flat", cmap="viridis" + ) + + cbar = figure.colorbar( + observation_tripcolor, + ax=axes, + label="Observation value", + orientation="vertical", + pad=0.15, + aspect=40, + ) + + cbar.ax.set_visible(plot_context.plotConfig().is_legend_enabled()) + config = plot_context.plotConfig() + axes.spines["top"].set_visible(False) + axes.spines["right"].set_visible(False) + axes.spines["left"].set_visible(False) + axes.spines["bottom"].set_visible(False) + axes.set_title(config.title() or "Observation map") + axes.ticklabel_format(useOffset=False, style="plain") + axes.set_aspect("equal") + axes.set_xlabel(config.x_label() or "east coordinate") + axes.set_ylabel(config.y_label() or "north coordinate") + axes.set_xlim(east.min(), east.max()) + axes.grid(config.is_grid_enabled()) + axes.set_ylim(north.min(), north.max()) diff --git a/src/ert/gui/plotting/plot_api.py b/src/ert/gui/plotting/plot_api.py index 7819ec6bc7d..5e116d5a69d 100644 --- a/src/ert/gui/plotting/plot_api.py +++ b/src/ert/gui/plotting/plot_api.py @@ -532,6 +532,10 @@ def observations_for_key(self, ensemble_ids: list[str], key: str) -> pd.DataFram "STD": obs["errors"], "OBS": obs["values"], "key_index": key_index, + "EAST": obs.get("east") + or [float("nan")] * len(obs["values"]), + "NORTH": obs.get("north") + or [float("nan")] * len(obs["values"]), } ) ) diff --git a/src/ert/gui/plotting/plot_window.py b/src/ert/gui/plotting/plot_window.py index c80b1a7c995..714c27e0b0e 100644 --- a/src/ert/gui/plotting/plot_window.py +++ b/src/ert/gui/plotting/plot_window.py @@ -42,6 +42,7 @@ EVEREST_PLOT_MAP, GAUSSIAN_KDE, HISTOGRAM, + MISFIT_MAP, MISFITS, SHARED_PLOT_MAP, STATISTICS, @@ -382,10 +383,30 @@ def _show_no_data_message(self) -> None: def get_plot_api_version(self) -> str: return self._api.api_version + def _apply_ensemble_selection_policy_for_tab(self, tab_name: str) -> None: + if tab_name == MISFIT_MAP: + self._ensemble_selection_widget.set_maximum_ensemble_limit(1) + if len(self._ensemble_selection_widget.get_selected_ensembles()) > 1: + self._ensemble_selection_widget.clear_ensemble_selection() + else: + self._ensemble_selection_widget.reset_maximum_ensemble_limit_to_default() + self._update_ensemble_group_title() + + def _update_ensemble_group_title(self) -> None: + max_selected = self._ensemble_selection_widget.get_maximum_ensemble_limit() + str_num_of_ens = f" up to {max_selected}" if self.is_everest else "" + self._ensemble_group.set_title( + f"Select{str_num_of_ens} batches" + if self.is_everest + else f"Select up to {max_selected} ensemble(s)" + ) + @Slot(int) def current_tab_changed(self, index: int) -> None: + tab_name = self._central_tab.tabText(index) + self._apply_ensemble_selection_policy_for_tab(tab_name) self.update_plot() - self.log_plot_tab_usage(self._central_tab.tabText(index)) + self.log_plot_tab_usage(tab_name) def log_plot_tab_usage(self, tab_name: str, *, default: bool = False) -> None: msg = f"Plotwindow tab used: {tab_name}" + (" (default tab)" if default else "") @@ -590,7 +611,9 @@ def fetch_data( plot_context, history_data_available=history_data_available, has_observations=key_def.observations, - show_observations=key_def.observations and selected_tab != MISFITS, + show_observations=key_def.observations + and selected_tab not in {MISFITS, MISFIT_MAP}, + show_color_palette=key_def.observations and selected_tab != MISFIT_MAP, log_scale_available=log_scale_valid_values and selected_tab in {HISTOGRAM, DISTRIBUTION, GAUSSIAN_KDE}, ) @@ -756,13 +779,7 @@ def keySelected(self) -> None: else: self._ensemble_selection_widget.reset_maximum_and_minimum_ensemble_limits_to_default() - max_selected = self._ensemble_selection_widget.get_maximum_ensemble_limit() - str_num_of_ens = f" up to {max_selected}" if self.is_everest else "" - self._ensemble_group.set_title( - f"Select{str_num_of_ens} batches" - if self.is_everest - else f"Select up to {max_selected} ensembles" - ) + self._update_ensemble_group_title() is_observed_seismic = ( key_def.observations @@ -775,7 +792,7 @@ def keySelected(self) -> None: if widget._plotter.dimensionality == key_def.dimensionality and (key_def.observations or not widget._plotter.requires_observations) and not is_everest_specific_widget - and (not is_observed_seismic or widget.name == MISFITS) + and (not is_observed_seismic or widget.name in {MISFITS, MISFIT_MAP}) ] def everest_data_origin_check(origin: list[str]) -> bool: @@ -831,6 +848,9 @@ def everest_available_widget_selection( current_widget = available_widgets[0] self._central_tab.setCurrentWidget(current_widget) + self._apply_ensemble_selection_policy_for_tab( + self._central_tab.tabText(self._central_tab.currentIndex()) + ) self._central_tab.currentChanged.connect(self.current_tab_changed) self._prev_key_dimensionality = key_def.dimensionality self._prev_key = key_def.key diff --git a/src/ert/gui/plotting/utils/plot_maps.py b/src/ert/gui/plotting/utils/plot_maps.py index 8ee1deceac4..2bd7cb57aa8 100644 --- a/src/ert/gui/plotting/utils/plot_maps.py +++ b/src/ert/gui/plotting/utils/plot_maps.py @@ -5,6 +5,7 @@ DistributionPlot, GaussianKDEPlot, HistogramPlot, + MisfitMapPlot, MisfitsPlot, StatisticsPlot, StdDevPlot, @@ -24,6 +25,7 @@ GAUSSIAN_KDE = "Gaussian KDE" ENSEMBLE = "Ensemble" HISTOGRAM = "Histogram" +MISFIT_MAP = "Misfit map" STATISTICS = "Statistics" STD_DEV = "Std dev" MISFITS = "Misfits" @@ -36,6 +38,7 @@ ERT_PLOT_MAP: dict[str, Callable[[], Plotter]] = { STATISTICS: StatisticsPlot, MISFITS: MisfitsPlot, + MISFIT_MAP: MisfitMapPlot, HISTOGRAM: HistogramPlot, GAUSSIAN_KDE: GaussianKDEPlot, DISTRIBUTION: DistributionPlot, diff --git a/src/ert/gui/plotting/widgets/plot_controls/general_options.py b/src/ert/gui/plotting/widgets/plot_controls/general_options.py index 7a3c92eab4c..126def6250e 100644 --- a/src/ert/gui/plotting/widgets/plot_controls/general_options.py +++ b/src/ert/gui/plotting/widgets/plot_controls/general_options.py @@ -99,8 +99,8 @@ def create_edit_button( ] ) - palette_container = QWidget() - palette_layout = QVBoxLayout(palette_container) + self._palette_container = QWidget() + palette_layout = QVBoxLayout(self._palette_container) palette_layout.setContentsMargins(0, 0, 0, 0) palette_layout.setSpacing(2) palette_layout.addWidget(QLabel("Selected color palette:")) @@ -109,7 +109,7 @@ def create_edit_button( palette_layout.addWidget(self._color_cycle_selector) palette_layout.addWidget(self._color_cycle_selector.get_custom_palette_button()) - widgets.extend([palette_container, edit_buttons]) + widgets.extend([self._palette_container, edit_buttons]) self._general_options = CollapsibleSection( "General options", @@ -167,6 +167,7 @@ def update_plot_context( history_data_available: bool, has_observations: bool, show_observations: bool, + show_color_palette: bool = True, log_scale_available: bool, ) -> None: plot_config = plot_context.plotConfig() @@ -185,6 +186,7 @@ def update_plot_context( self._observations_color_edit.setVisible( show_observations and self.observations_checkbox_state ) + self._palette_container.setVisible(show_color_palette) plot_config.set_history_enabled( self.history_checkbox_state and history_data_available ) diff --git a/src/ert/gui/plotting/widgets/plot_ensemble_selection_widget.py b/src/ert/gui/plotting/widgets/plot_ensemble_selection_widget.py index bfc8f24a9ed..dcf941cf915 100644 --- a/src/ert/gui/plotting/widgets/plot_ensemble_selection_widget.py +++ b/src/ert/gui/plotting/widgets/plot_ensemble_selection_widget.py @@ -140,12 +140,7 @@ def __init__( it.setData(Qt.ItemDataRole.CheckStateRole, i < cutoff) self.addItem(it) self._ensemble_count += 1 - it.setToolTip( - f"{item_text}\n" - f"Toggle up to {self.get_maximum_ensemble_limit()} plots or reorder by" - "drag & drop\n" - f"Order determines draw order and color" - ) + self._tooltip_for_ensemble_selection(it) if (viewport := self.viewport()) is not None: viewport.setMouseTracking(True) @@ -262,23 +257,41 @@ def dropEvent(self, event: QDropEvent | None) -> None: super().dropEvent(event) self.ensembleSelectionListChanged.emit() + def _uncheck_item(self, item: QListWidgetItem) -> None: + self.release_color(item.data(EnsembleSelectListWidgetItemDataRole.COLOR_INDEX)) + item.setData(Qt.ItemDataRole.CheckStateRole, False) + + def _check_item(self, item: QListWidgetItem) -> None: + item.setData( + EnsembleSelectListWidgetItemDataRole.COLOR_INDEX, + self.assign_available_color( + item.data(EnsembleSelectListWidgetItemDataRole.COLOR_INDEX) + ), + ) + item.setData(Qt.ItemDataRole.CheckStateRole, True) + def slot_toggle_plot(self, item: QListWidgetItem) -> None: count = len(self.get_checked_ensembles()) selected = item.data(Qt.ItemDataRole.CheckStateRole) + if not selected and self.get_maximum_ensemble_limit() == 1 and count == 1: + for ensemble_index in range(self._ensemble_count): + ensemble_item = self.item(ensemble_index) + if ( + ensemble_item is not None + and ensemble_item is not item + and ensemble_item.data(Qt.ItemDataRole.CheckStateRole) + ): + self._uncheck_item(ensemble_item) + break + self._check_item(item) + self.ensembleSelectionListChanged.emit() + return + if selected and count > self.get_minimum_ensemble_limit(): - self.release_color( - item.data(EnsembleSelectListWidgetItemDataRole.COLOR_INDEX) - ) - item.setData(Qt.ItemDataRole.CheckStateRole, False) + self._uncheck_item(item) elif not selected and count < self.get_maximum_ensemble_limit(): - item.setData( - EnsembleSelectListWidgetItemDataRole.COLOR_INDEX, - self.assign_available_color( - item.data(EnsembleSelectListWidgetItemDataRole.COLOR_INDEX) - ), - ) - item.setData(Qt.ItemDataRole.CheckStateRole, True) + self._check_item(item) self.ensembleSelectionListChanged.emit() @@ -294,6 +307,18 @@ def set_maximum_ensemble_limit(self, value: int) -> None: f"Maximum selected ensembles limit ({value}) cannot be less than 1" ) self._maximum_selected = value + for i in range(self._ensemble_count): + if (item := self.item(i)) is not None: + self._tooltip_for_ensemble_selection(item) + + def _tooltip_for_ensemble_selection(self, item: QListWidgetItem) -> None: + item_text = item.text() + item.setToolTip( + f"{item_text}\n" + f"Toggle up to {self.get_maximum_ensemble_limit()} plot(s) or reorder " + "by drag & drop\n" + f"Order determines draw order and color" + ) def get_maximum_ensemble_limit(self) -> int: return self._maximum_selected diff --git a/tests/ert/unit_tests/gui/plotting/widgets/test_general_options.py b/tests/ert/unit_tests/gui/plotting/widgets/test_general_options.py index 33ded8c91a8..1dd24c5c4f6 100644 --- a/tests/ert/unit_tests/gui/plotting/widgets/test_general_options.py +++ b/tests/ert/unit_tests/gui/plotting/widgets/test_general_options.py @@ -33,6 +33,7 @@ def _apply_options_to_plot_context( has_observations: bool = False, show_observations: bool = False, log_scale_available: bool = False, + show_color_palette: bool = False, ) -> None: options.update_plot_context( plot_context, @@ -40,6 +41,7 @@ def _apply_options_to_plot_context( has_observations=has_observations, show_observations=show_observations, log_scale_available=log_scale_available, + show_color_palette=show_color_palette, ) diff --git a/tests/ert/unit_tests/gui/tools/plot/conftest.py b/tests/ert/unit_tests/gui/tools/plot/conftest.py index d549e8551a4..88b15379336 100644 --- a/tests/ert/unit_tests/gui/tools/plot/conftest.py +++ b/tests/ert/unit_tests/gui/tools/plot/conftest.py @@ -124,6 +124,9 @@ def mocked_requests_get(*args, **kwargs): "errors": [0.05, 0.07], "values": [0.1, 0.7], "x_axis": ["2010-03-31T00:00:00", "2010-12-26T00:00:00"], + "east": [None, None], + "north": [None, None], + "radius": [None, None], } ], } diff --git a/tests/ert/unit_tests/gui/tools/plot/test_plot_api.py b/tests/ert/unit_tests/gui/tools/plot/test_plot_api.py index 6056fb325db..d893bc6643e 100644 --- a/tests/ert/unit_tests/gui/tools/plot/test_plot_api.py +++ b/tests/ert/unit_tests/gui/tools/plot/test_plot_api.py @@ -271,6 +271,8 @@ def test_plot_api_handles_urlescape(api_and_storage): STD,1.0 OBS,1.0 key_index,2024-10-04 00:00:00 + EAST, + NORTH, """ ) @@ -413,7 +415,7 @@ def test_that_multiple_observations_are_parsed_correctly(api_and_storage): ensemble = next(x for x in api.get_all_ensembles() if x.id == str(ens.id)) obs_data = api.observations_for_key([ensemble.id], "WOPR:OP1") - assert obs_data.shape == (3, 6) + assert obs_data.shape == (5, 6) def test_that_observations_for_empty_ensemble_returns_empty_data(api_and_storage):