diff --git a/src/ert/dark_storage/endpoints/records.py b/src/ert/dark_storage/endpoints/records.py index 5829620216c..673594ec80c 100644 --- a/src/ert/dark_storage/endpoints/records.py +++ b/src/ert/dark_storage/endpoints/records.py @@ -1,11 +1,10 @@ -import base64 import io from itertools import chain from typing import Any, Dict, List, Mapping, Union from uuid import UUID, uuid4 -import matplotlib.pyplot as plt -from fastapi import APIRouter, Body, Depends, File, Header, status +import numpy as np +from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, status from fastapi.responses import Response from typing_extensions import Annotated @@ -141,22 +140,22 @@ def get_ensemble_responses( return response_map -@router.get( - "/ensembles/{ensemble_id}/records/{key}/std_dev", response_model=js.ImageOut -) +@router.get("/ensembles/{ensemble_id}/records/{key}/std_dev") def get_std_dev( *, storage: Storage = DEFAULT_STORAGE, ensemble_id: UUID, key: str, z: int -) -> js.ImageOut: +) -> Response: ensemble = storage.get_ensemble(ensemble_id) try: da = ensemble.calculate_std_dev_for_parameter(key)["values"] - except ValueError: - return js.ImageOut(image=bytearray()) + except ValueError as e: + raise HTTPException(status_code=404, detail="Data not found") from e if z >= int(da.shape[2]): - return js.ImageOut(image=bytearray()) + raise HTTPException(status_code=400, detail="Invalid z index") + + data_2d = da[:, :, z] buffer = io.BytesIO() - plt.imsave(buffer, da[:, :, z]) + np.save(buffer, data_2d) - return js.ImageOut(image=base64.b64encode(buffer.getvalue())) + return Response(content=buffer.getvalue(), media_type="application/octet-stream") diff --git a/src/ert/dark_storage/json_schema/__init__.py b/src/ert/dark_storage/json_schema/__init__.py index 800c58e2cfb..8692ecf41f2 100644 --- a/src/ert/dark_storage/json_schema/__init__.py +++ b/src/ert/dark_storage/json_schema/__init__.py @@ -7,5 +7,5 @@ ObservationTransformationOut, ) from .prior import Prior -from .record import ImageOut, RecordOut +from .record import RecordOut from .update import UpdateIn, UpdateOut diff --git a/src/ert/dark_storage/json_schema/record.py b/src/ert/dark_storage/json_schema/record.py index eb6c1d5439e..2ce03df2498 100644 --- a/src/ert/dark_storage/json_schema/record.py +++ b/src/ert/dark_storage/json_schema/record.py @@ -16,8 +16,3 @@ class RecordOut(_Record): name: str userdata: Mapping[str, Any] has_observations: Optional[bool] - - -@dataclass(config=ConfigDict(from_attributes=True)) -class ImageOut(_Record): - image: bytes diff --git a/src/ert/gui/ertwidgets/__init__.py b/src/ert/gui/ertwidgets/__init__.py index f6f4c7a00cc..0e4d8919a8f 100644 --- a/src/ert/gui/ertwidgets/__init__.py +++ b/src/ert/gui/ertwidgets/__init__.py @@ -19,21 +19,45 @@ def wrapper(*arg: Any) -> Any: return wrapper -# The following imports utilize the functions defined above: -from .legend import Legend # noqa -from .validationsupport import ValidationSupport # noqa -from .closabledialog import ClosableDialog # noqa -from .analysismoduleedit import AnalysisModuleEdit # noqa -from .activelabel import ActiveLabel # noqa -from .searchbox import SearchBox # noqa -from .ensembleselector import EnsembleSelector # noqa -from .ensemblelist import EnsembleList # noqa -from .checklist import CheckList # noqa -from .stringbox import StringBox # noqa -from .listeditbox import ListEditBox # noqa -from .customdialog import CustomDialog # noqa -from .summarypanel import SummaryPanel # noqa -from .pathchooser import PathChooser # noqa -from .models import TextModel # noqa +from .closabledialog import ClosableDialog +from .analysismoduleedit import AnalysisModuleEdit +from .searchbox import SearchBox +from .ensembleselector import EnsembleSelector +from .checklist import CheckList +from .stringbox import StringBox +from .listeditbox import ListEditBox +from .customdialog import CustomDialog +from .pathchooser import PathChooser +from .models import ( + TextModel, + ActiveRealizationsModel, + TargetEnsembleModel, + ValueModel, + SelectableListModel, + PathModel, +) +from .copyablelabel import CopyableLabel +from .message_box import ErtMessageBox +from .copy_button import CopyButton -__all__ = ["TextModel"] +__all__ = [ + "TextModel", + "ClosableDialog", + "AnalysisModuleEdit", + "SearchBox", + "EnsembleSelector", + "ActiveRealizationsModel", + "CheckList", + "StringBox", + "CopyableLabel", + "showWaitCursorWhileWaiting", + "ErtMessageBox", + "TargetEnsembleModel", + "ValueModel", + "PathModel", + "SelectableListModel", + "ListEditBox", + "CustomDialog", + "PathChooser", + "CopyButton", +] diff --git a/src/ert/gui/ertwidgets/activelabel.py b/src/ert/gui/ertwidgets/activelabel.py deleted file mode 100644 index 8b13da1fa37..00000000000 --- a/src/ert/gui/ertwidgets/activelabel.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from qtpy.QtGui import QFont -from qtpy.QtWidgets import QLabel - -if TYPE_CHECKING: - from .models.valuemodel import ValueModel - - -class ActiveLabel(QLabel): - def __init__(self, model: ValueModel) -> None: - QLabel.__init__(self) - - self._model = model - - font = self.font() - font.setWeight(QFont.Bold) - self.setFont(font) - - self._model.valueChanged.connect(self.updateLabel) - - self.updateLabel() - - def updateLabel(self) -> None: - """Retrieves data from the model and inserts it into the edit line""" - model_value = self._model.getValue() - if model_value is None: - model_value = "" - - self.setText(str(model_value)) diff --git a/src/ert/gui/ertwidgets/copy_button.py b/src/ert/gui/ertwidgets/copy_button.py new file mode 100644 index 00000000000..0b41d836c0a --- /dev/null +++ b/src/ert/gui/ertwidgets/copy_button.py @@ -0,0 +1,43 @@ +from abc import abstractmethod + +from qtpy.QtCore import QTimer +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import ( + QApplication, + QMessageBox, + QPushButton, + QSizePolicy, +) + + +class CopyButton(QPushButton): + def __init__(self) -> None: + super().__init__() + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.setIcon(QIcon("img:copy.svg")) + self.restore_timer = QTimer(self) + + def restore_text() -> None: + self.setIcon(QIcon("img:copy.svg")) + + self.restore_timer.timeout.connect(restore_text) + + self.clicked.connect(self.copy) + + @abstractmethod + def copy(self) -> None: + pass + + def copy_text(self, text: str) -> None: + clipboard = QApplication.clipboard() + if clipboard: + clipboard.setText(text) + else: + QMessageBox.critical( + None, + "Error", + "Cannot copy text to clipboard because your system does not have a clipboard", + QMessageBox.Ok, + ) + self.setIcon(QIcon("img:check.svg")) + self.restore_timer.start(1000) diff --git a/src/ert/gui/ertwidgets/copyablelabel.py b/src/ert/gui/ertwidgets/copyablelabel.py index 87d1e82260a..d689f196441 100644 --- a/src/ert/gui/ertwidgets/copyablelabel.py +++ b/src/ert/gui/ertwidgets/copyablelabel.py @@ -1,16 +1,13 @@ from os import path -from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QIcon +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( - QApplication, QHBoxLayout, QLabel, - QMessageBox, - QPushButton, - QSizePolicy, ) +from .copy_button import CopyButton + # Get the absolute path of the directory that contains the current script current_dir = path.dirname(path.abspath(__file__)) @@ -63,6 +60,17 @@ def strip_run_path_magic_keywords(run_path: str) -> str: return rp_stripped +class _CopyButton(CopyButton): + def __init__(self, label: QLabel) -> None: + super().__init__() + self.label = label + + def copy(self) -> None: + self.copy_text( + strip_run_path_magic_keywords(unescape_string(self.label.text())) + ) + + class CopyableLabel(QHBoxLayout): """CopyableLabel shows a string that is copyable via selection or clicking of a copy button""" @@ -73,35 +81,7 @@ def __init__(self, text: str) -> None: self.label = QLabel(f"{escape_string(text)}") self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.copy_button = QPushButton("") - self.copy_button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.copy_button.setIcon(QIcon("img:copy.svg")) - self.restore_timer = QTimer(self) - - def restore_text() -> None: - self.copy_button.setIcon(QIcon("img:copy.svg")) - - self.restore_timer.timeout.connect(restore_text) - - def copy_text() -> None: - text = strip_run_path_magic_keywords(unescape_string(self.label.text())) - - clipboard = QApplication.clipboard() - if clipboard: - clipboard.setText(text) - else: - QMessageBox.critical( - None, - "Error", - "Cannot copy text to clipboard because your system does not have a clipboard", - QMessageBox.Ok, - ) - - self.copy_button.setIcon(QIcon("img:check.svg")) - - self.restore_timer.start(1000) - - self.copy_button.clicked.connect(copy_text) + self.copy_button = _CopyButton(self.label) self.addWidget(self.label) self.addWidget(self.copy_button, alignment=Qt.AlignmentFlag.AlignLeft) diff --git a/src/ert/gui/ertwidgets/ensemblelist.py b/src/ert/gui/ertwidgets/ensemblelist.py deleted file mode 100644 index aca21b836ac..00000000000 --- a/src/ert/gui/ertwidgets/ensemblelist.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Callable - -from qtpy.QtCore import QSize, Qt -from qtpy.QtGui import QIcon -from qtpy.QtWidgets import ( - QAbstractItemView, - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QToolButton, - QVBoxLayout, - QWidget, -) - -from ert.config import ErtConfig -from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.validateddialog import ValidatedDialog -from ert.storage import Storage - - -class AddWidget(QWidget): - """ - A widget with an add button. - - Parameters - ---------- - addFunction: Callable to be connected to the add button. - """ - - def __init__(self, addFunction: Callable[[], None]) -> None: - super().__init__() - - self.addButton = QToolButton(self) - self.addButton.setIcon(QIcon("img:add_circle_outlined.svg")) - self.addButton.setIconSize(QSize(16, 16)) - self.addButton.clicked.connect(addFunction) - - self.removeButton = None - - self.buttonLayout = QHBoxLayout() - self.buttonLayout.setContentsMargins(0, 0, 0, 0) - self.buttonLayout.addStretch(1) - self.buttonLayout.addWidget(self.addButton) - self.buttonLayout.addSpacing(2) - - self.setLayout(self.buttonLayout) - - -class EnsembleList(QWidget): - def __init__(self, config: ErtConfig, notifier: ErtNotifier, ensemble_size: int): - self.ert_config = config - self.ensemble_size = ensemble_size - self.notifier = notifier - QWidget.__init__(self) - - layout = QVBoxLayout() - - self._list = QListWidget(self) - self._default_selection_mode = self._list.selectionMode() - self._list.setSelectionMode(QAbstractItemView.NoSelection) - - layout.addWidget(QLabel("Available ensembles:")) - layout.addWidget(self._list, stretch=1) - - self._addWidget = AddWidget(self.addItem) - layout.addWidget(self._addWidget) - - self._title = "New keyword" - self._description = "Enter name of keyword:" - - self.setLayout(layout) - - notifier.ertChanged.connect(self.updateList) - self.updateList() - - @property - def storage(self) -> Storage: - return self.notifier.storage - - def addItem(self) -> None: - dialog = ValidatedDialog( - "New ensemble", - "Enter name of new ensemble:", - [x.name for x in self.storage.ensembles], - parent=self, - ) - new_ensemble_name = dialog.showAndTell() - if new_ensemble_name: - ensemble = self.storage.create_experiment( - parameters=self.ert_config.ensemble_config.parameter_configuration, - responses=self.ert_config.ensemble_config.response_configuration, - observations=self.ert_config.observations, - ).create_ensemble( - name=new_ensemble_name, - ensemble_size=self.ensemble_size, - ) - self.notifier.set_current_ensemble(ensemble) - self.notifier.ertChanged.emit() - - def updateList(self) -> None: - """Retrieves data from the model and inserts it into the list""" - ensemble_list = sorted( - self.storage.ensembles, key=lambda x: x.started_at, reverse=True - ) - - self._list.clear() - - for ensemble in ensemble_list: - item = QListWidgetItem( - f"{ensemble.name} - {ensemble.started_at} ({ensemble.id})" - ) - item.setData(Qt.ItemDataRole.UserRole, ensemble) - self._list.addItem(item) diff --git a/src/ert/gui/ertwidgets/legend.py b/src/ert/gui/ertwidgets/legend.py deleted file mode 100644 index 9733cf3c2e2..00000000000 --- a/src/ert/gui/ertwidgets/legend.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Optional - -from qtpy.QtCore import QSize -from qtpy.QtGui import QColor, QPainter, QPaintEvent -from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget - - -class LegendMarker(QWidget): - """A widget that shows a colored box""" - - def __init__(self, color: QColor): - QWidget.__init__(self) - - self.setMaximumSize(QSize(12, 12)) - self.setMinimumSize(QSize(12, 12)) - - self.color = color - - def paintEvent(self, a0: Optional[QPaintEvent]) -> None: - painter = QPainter(self) - - rect = self.contentsRect() - rect.setWidth(rect.width() - 1) - rect.setHeight(rect.height() - 1) - painter.drawRect(rect) - - rect.setX(rect.x() + 1) - rect.setY(rect.y() + 1) - painter.fillRect(rect, self.color) - - -class Legend(QWidget): - """Combines a LegendMarker with a label""" - - def __init__(self, legend: Optional[str], color: QColor): - QWidget.__init__(self) - - self.setMinimumWidth(140) - self.setMaximumHeight(25) - - self.legend = legend - - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - - self.legend_marker = LegendMarker(color) - self.legend_marker.setToolTip(legend) - - layout.addWidget(self.legend_marker) - self.legend_label = QLabel(legend) - layout.addWidget(self.legend_label) - layout.addStretch() - - self.setLayout(layout) diff --git a/src/ert/gui/ertwidgets/listeditbox.py b/src/ert/gui/ertwidgets/listeditbox.py index 195ead253f3..64ef9cca3f0 100644 --- a/src/ert/gui/ertwidgets/listeditbox.py +++ b/src/ert/gui/ertwidgets/listeditbox.py @@ -12,7 +12,7 @@ QWidget, ) -from ert.gui.ertwidgets.validationsupport import ValidationSupport +from .validationsupport import ValidationSupport class AutoCompleteLineEdit(QLineEdit): diff --git a/src/ert/gui/ertwidgets/models/__init__.py b/src/ert/gui/ertwidgets/models/__init__.py index f375244af42..327dc3f5b2e 100644 --- a/src/ert/gui/ertwidgets/models/__init__.py +++ b/src/ert/gui/ertwidgets/models/__init__.py @@ -1,5 +1,15 @@ +from .activerealizationsmodel import ActiveRealizationsModel +from .path_model import PathModel +from .selectable_list_model import SelectableListModel +from .targetensemblemodel import TargetEnsembleModel from .text_model import TextModel +from .valuemodel import ValueModel __all__ = [ "TextModel", + "ActiveRealizationsModel", + "ValueModel", + "TargetEnsembleModel", + "SelectableListModel", + "PathModel", ] diff --git a/src/ert/gui/ertwidgets/pathchooser.py b/src/ert/gui/ertwidgets/pathchooser.py index 4875601eb2b..b53efd4cfa1 100644 --- a/src/ert/gui/ertwidgets/pathchooser.py +++ b/src/ert/gui/ertwidgets/pathchooser.py @@ -8,7 +8,7 @@ from qtpy.QtGui import QIcon from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLineEdit, QToolButton, QWidget -from ert.gui.ertwidgets.validationsupport import ValidationSupport +from .validationsupport import ValidationSupport if TYPE_CHECKING: from .models.path_model import PathModel diff --git a/src/ert/gui/ertwidgets/stringbox.py b/src/ert/gui/ertwidgets/stringbox.py index 8ffc0bf37d6..4bbfabe2e3e 100644 --- a/src/ert/gui/ertwidgets/stringbox.py +++ b/src/ert/gui/ertwidgets/stringbox.py @@ -5,7 +5,7 @@ from qtpy.QtGui import QPalette from qtpy.QtWidgets import QLineEdit -from ert.gui.ertwidgets import ValidationSupport +from .validationsupport import ValidationSupport if TYPE_CHECKING: from ert.validation import ArgumentDefinition diff --git a/src/ert/gui/main.py b/src/ert/gui/main.py index 155d3734740..03d5c4a0f2b 100755 --- a/src/ert/gui/main.py +++ b/src/ert/gui/main.py @@ -18,7 +18,6 @@ from qtpy.QtWidgets import QApplication, QWidget from ert.config import ConfigValidationError, ConfigWarning, ErtConfig -from ert.gui.ertwidgets import SummaryPanel from ert.gui.main_window import ErtMainWindow from ert.gui.simulation import ExperimentPanel from ert.gui.tools.event_viewer import ( @@ -41,6 +40,7 @@ from ert.storage.local_storage import local_storage_set_ert_config from .suggestor import Suggestor +from .summarypanel import SummaryPanel if TYPE_CHECKING: from ert.config import ParameterConfig diff --git a/src/ert/gui/plottery/plots/cesp.py b/src/ert/gui/plottery/plots/cesp.py index a8c8763a278..c6adbfca7cd 100644 --- a/src/ert/gui/plottery/plots/cesp.py +++ b/src/ert/gui/plottery/plots/cesp.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Dict, List, TypedDict +import numpy as np import pandas as pd from matplotlib.lines import Line2D from matplotlib.patches import Rectangle @@ -11,6 +12,7 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure from pandas import DataFrame @@ -45,7 +47,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: plotCrossEnsembleStatistics( figure, plot_context, ensemble_to_data_map, observation_data diff --git a/src/ert/gui/plottery/plots/distribution.py b/src/ert/gui/plottery/plots/distribution.py index 6d527a4c8b0..e8136458e76 100644 --- a/src/ert/gui/plottery/plots/distribution.py +++ b/src/ert/gui/plottery/plots/distribution.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional +import numpy as np import pandas as pd from ert.gui.tools.plot.plot_api import EnsembleObject @@ -9,6 +10,7 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -25,7 +27,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: plotDistribution(figure, plot_context, ensemble_to_data_map, observation_data) diff --git a/src/ert/gui/plottery/plots/ensemble.py b/src/ert/gui/plottery/plots/ensemble.py index 8ff70300cf2..cec97230ea8 100644 --- a/src/ert/gui/plottery/plots/ensemble.py +++ b/src/ert/gui/plottery/plots/ensemble.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Dict +import numpy as np import pandas as pd from ert.gui.plottery.plots.history import plotHistory @@ -11,6 +12,7 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -27,7 +29,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: config = plot_context.plotConfig() axes = figure.add_subplot(111) diff --git a/src/ert/gui/plottery/plots/gaussian_kde.py b/src/ert/gui/plottery/plots/gaussian_kde.py index 0e38ae87612..79e701771c2 100644 --- a/src/ert/gui/plottery/plots/gaussian_kde.py +++ b/src/ert/gui/plottery/plots/gaussian_kde.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Dict -import numpy +import numpy as np import pandas as pd from scipy.stats import gaussian_kde @@ -11,6 +11,7 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -27,7 +28,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: plotGaussianKDE(figure, plot_context, ensemble_to_data_map, observation_data) @@ -69,7 +70,7 @@ def _plotGaussianKDE( style = plot_config.histogramStyle() sample_range = data.max() - data.min() - indexes = numpy.linspace( + indexes = np.linspace( data.min() - 0.5 * sample_range, data.max() + 0.5 * sample_range, 1000 ) gkde = gaussian_kde(data.values) diff --git a/src/ert/gui/plottery/plots/histogram.py b/src/ert/gui/plottery/plots/histogram.py index f8ecc56b6c3..4a6797c535a 100644 --- a/src/ert/gui/plottery/plots/histogram.py +++ b/src/ert/gui/plottery/plots/histogram.py @@ -3,7 +3,7 @@ from math import ceil, floor, log10, sqrt from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union -import numpy +import numpy as np import pandas as pd from matplotlib.patches import Rectangle @@ -12,9 +12,9 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure - from numpy.typing import NDArray from ert.gui.plottery import PlotContext, PlotStyle @@ -29,7 +29,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: plotHistogram(figure, plot_context, ensemble_to_data_map, observation_data) @@ -173,7 +173,7 @@ def _plotCategoricalHistogram( ) -> Rectangle: counts = data.value_counts() freq = [counts.get(category, 0) for category in categories] - pos = numpy.arange(len(categories)) + pos = np.arange(len(categories)) width = 1.0 axes.set_xticks(pos + (width / 2.0)) axes.set_xticklabels(categories) @@ -199,7 +199,7 @@ def _plotHistogram( if use_log_scale: bins = _histogramLogBins(bin_count, minimum, maximum) # type: ignore else: - bins = numpy.linspace(minimum, maximum, bin_count) # type: ignore + bins = np.linspace(minimum, maximum, bin_count) # type: ignore if minimum == maximum: minimum -= 0.5 @@ -218,7 +218,7 @@ def _plotHistogram( def _histogramLogBins( bin_count: int, minimum: float, maximum: float -) -> NDArray[numpy.floating[Any]]: +) -> npt.NDArray[np.floating[Any]]: minimum = log10(float(minimum)) maximum = log10(float(maximum)) @@ -235,4 +235,4 @@ def _histogramLogBins( else: log_bin_count = bin_count - return 10 ** numpy.linspace(minimum, maximum, log_bin_count) + return 10 ** np.linspace(minimum, maximum, log_bin_count) diff --git a/src/ert/gui/plottery/plots/statistics.py b/src/ert/gui/plottery/plots/statistics.py index 9c20e033268..4117ef5c70a 100644 --- a/src/ert/gui/plottery/plots/statistics.py +++ b/src/ert/gui/plottery/plots/statistics.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING, Dict +import numpy as np from matplotlib.lines import Line2D from matplotlib.patches import Rectangle -from numpy.typing import ArrayLike from pandas import DataFrame from ert.gui.plottery import PlotConfig, PlotContext, PlotStyle @@ -14,6 +14,7 @@ from .plot_tools import PlotTools if TYPE_CHECKING: + import numpy.typing as npt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -30,7 +31,7 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, DataFrame], _observation_data: DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: config = plot_context.plotConfig() axes = figure.add_subplot(111) @@ -176,9 +177,9 @@ def _plotPercentiles( def _plotPercentile( axes: Axes, style: PlotStyle, - index_values: ArrayLike, - top_line_data: ArrayLike, - bottom_line_data: ArrayLike, + index_values: npt.ArrayLike, + top_line_data: npt.ArrayLike, + bottom_line_data: npt.ArrayLike, alpha_multiplier: float, ) -> None: alpha = style.alpha diff --git a/src/ert/gui/plottery/plots/std_dev.py b/src/ert/gui/plottery/plots/std_dev.py index 05087ce4424..ee8c60159ae 100644 --- a/src/ert/gui/plottery/plots/std_dev.py +++ b/src/ert/gui/plottery/plots/std_dev.py @@ -1,9 +1,9 @@ -import io -from typing import Any, Dict +from typing import Any, Dict, List import matplotlib.pyplot as plt +import numpy as np +import numpy.typing as npt import pandas as pd -from matplotlib.collections import QuadMesh from matplotlib.figure import Figure from mpl_toolkits.axes_grid1 import make_axes_locatable @@ -21,15 +21,20 @@ def plot( plot_context: PlotContext, ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observation_data: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_data: Dict[str, npt.NDArray[np.float32]], ) -> None: ensemble_count = len(plot_context.ensembles()) layer = plot_context.layer if layer is not None: + vmin: float = np.inf + vmax: float = -np.inf + axes = [] + images: List[npt.NDArray[np.float32]] = [] for i, ensemble in enumerate(plot_context.ensembles(), start=1): ax = figure.add_subplot(1, ensemble_count, i) - images = std_dev_images[ensemble.name] - if not images: + axes.append(ax) + data = std_dev_data[ensemble.name] + if data.size == 0: ax.set_axis_off() ax.text( 0.5, @@ -39,16 +44,23 @@ def plot( va="center", ) else: - img = plt.imread(io.BytesIO(images)) - ax.imshow(img) - ax.set_title( - f"{ensemble.experiment_name} : {ensemble.name} layer={layer}" - ) - p = ax.pcolormesh(img) - self._colorbar(p) + images.append(data) + vmin = min(vmin, float(np.min(data))) + vmax = max(vmax, float(np.max(data))) + ax.set_title( + f"{ensemble.experiment_name} : {ensemble.name} layer={layer}", + wrap=True, + ) + + norm = plt.Normalize(vmin, vmax) + for ax, data in zip(axes, images): + if data is not None: + im = ax.imshow(data, norm=norm, cmap="viridis") + self._colorbar(im) + figure.tight_layout() @staticmethod - def _colorbar(mappable: QuadMesh) -> Any: + def _colorbar(mappable: Any) -> Any: # https://joseph-long.com/writing/colorbars/ last_axes = plt.gca() ax = mappable.axes diff --git a/src/ert/gui/simulation/ensemble_experiment_panel.py b/src/ert/gui/simulation/ensemble_experiment_panel.py index db20eaeceef..ad51aa5c21c 100644 --- a/src/ert/gui/simulation/ensemble_experiment_panel.py +++ b/src/ert/gui/simulation/ensemble_experiment_panel.py @@ -5,9 +5,12 @@ from qtpy.QtWidgets import QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import StringBox, TextModel -from ert.gui.ertwidgets.copyablelabel import CopyableLabel -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel +from ert.gui.ertwidgets import ( + ActiveRealizationsModel, + CopyableLabel, + StringBox, + TextModel, +) from ert.mode_definitions import ENSEMBLE_EXPERIMENT_MODE from ert.run_models import EnsembleExperiment from ert.validation import RangeStringArgument diff --git a/src/ert/gui/simulation/ensemble_smoother_panel.py b/src/ert/gui/simulation/ensemble_smoother_panel.py index dda07783fa7..6c882ee4ce3 100644 --- a/src/ert/gui/simulation/ensemble_smoother_panel.py +++ b/src/ert/gui/simulation/ensemble_smoother_panel.py @@ -7,10 +7,14 @@ from qtpy.QtWidgets import QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import AnalysisModuleEdit, StringBox, TextModel -from ert.gui.ertwidgets.copyablelabel import CopyableLabel -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel -from ert.gui.ertwidgets.models.targetensemblemodel import TargetEnsembleModel +from ert.gui.ertwidgets import ( + ActiveRealizationsModel, + AnalysisModuleEdit, + CopyableLabel, + StringBox, + TargetEnsembleModel, + TextModel, +) from ert.mode_definitions import ENSEMBLE_SMOOTHER_MODE from ert.run_models import EnsembleSmoother from ert.validation import ProperNameFormatArgument, RangeStringArgument diff --git a/src/ert/gui/simulation/evaluate_ensemble_panel.py b/src/ert/gui/simulation/evaluate_ensemble_panel.py index 44417ff4781..44d7400c4ef 100644 --- a/src/ert/gui/simulation/evaluate_ensemble_panel.py +++ b/src/ert/gui/simulation/evaluate_ensemble_panel.py @@ -5,10 +5,12 @@ from qtpy.QtWidgets import QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.copyablelabel import CopyableLabel -from ert.gui.ertwidgets.ensembleselector import EnsembleSelector -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel -from ert.gui.ertwidgets.stringbox import StringBox +from ert.gui.ertwidgets import ( + ActiveRealizationsModel, + CopyableLabel, + EnsembleSelector, + StringBox, +) from ert.gui.simulation.experiment_config_panel import ExperimentConfigPanel from ert.mode_definitions import EVALUATE_ENSEMBLE_MODE from ert.run_models.evaluate_ensemble import EvaluateEnsemble diff --git a/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py b/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py index 744e32223a3..bc39e666f22 100644 --- a/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py +++ b/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py @@ -7,10 +7,14 @@ from qtpy.QtWidgets import QFormLayout, QLabel, QSpinBox from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import AnalysisModuleEdit, StringBox, TextModel -from ert.gui.ertwidgets.copyablelabel import CopyableLabel -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel -from ert.gui.ertwidgets.models.targetensemblemodel import TargetEnsembleModel +from ert.gui.ertwidgets import ( + ActiveRealizationsModel, + AnalysisModuleEdit, + CopyableLabel, + StringBox, + TargetEnsembleModel, + TextModel, +) from ert.mode_definitions import ITERATIVE_ENSEMBLE_SMOOTHER_MODE from ert.run_models import IteratedEnsembleSmoother from ert.validation import ProperNameFormatArgument, RangeStringArgument diff --git a/src/ert/gui/simulation/multiple_data_assimilation_panel.py b/src/ert/gui/simulation/multiple_data_assimilation_panel.py index e6af4bd9a67..052276185f0 100644 --- a/src/ert/gui/simulation/multiple_data_assimilation_panel.py +++ b/src/ert/gui/simulation/multiple_data_assimilation_panel.py @@ -4,20 +4,20 @@ from typing import TYPE_CHECKING, Any, List from qtpy.QtCore import Slot +from qtpy.QtGui import QFont from qtpy.QtWidgets import QCheckBox, QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier from ert.gui.ertwidgets import ( - ActiveLabel, + ActiveRealizationsModel, AnalysisModuleEdit, + CopyableLabel, EnsembleSelector, StringBox, + TargetEnsembleModel, TextModel, + ValueModel, ) -from ert.gui.ertwidgets.copyablelabel import CopyableLabel -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel -from ert.gui.ertwidgets.models.targetensemblemodel import TargetEnsembleModel -from ert.gui.ertwidgets.models.valuemodel import ValueModel from ert.mode_definitions import ES_MDA_MODE from ert.run_models import MultipleDataAssimilation from ert.validation import ( @@ -31,6 +31,7 @@ if TYPE_CHECKING: from ert.config import AnalysisConfig + from ert.gui.ertwidgets import ValueModel @dataclass @@ -176,7 +177,7 @@ def _createInputForWeights(self, layout: QFormLayout) -> None: relative_iteration_weights_model.valueChanged.connect(self.setWeights) normalized_weights_model = ValueModel() - normalized_weights_widget = ActiveLabel(normalized_weights_model) + normalized_weights_widget = _ActiveLabel(normalized_weights_model) layout.addRow("Normalized weights:", normalized_weights_widget) def updateVisualizationOfNormalizedWeights() -> None: @@ -233,3 +234,26 @@ def _realizations_from_fs(self) -> None: if ensemble: mask = ensemble.get_realization_mask_with_parameters() self._active_realizations_field.model.setValueFromMask(mask) # type: ignore + + +class _ActiveLabel(QLabel): + def __init__(self, model: ValueModel) -> None: + QLabel.__init__(self) + + self._model = model + + font = self.font() + font.setWeight(QFont.Bold) + self.setFont(font) + + self._model.valueChanged.connect(self.updateLabel) + + self.updateLabel() + + def updateLabel(self) -> None: + """Retrieves data from the model and inserts it into the edit line""" + model_value = self._model.getValue() + if model_value is None: + model_value = "" + + self.setText(str(model_value)) diff --git a/src/ert/gui/simulation/single_test_run_panel.py b/src/ert/gui/simulation/single_test_run_panel.py index f68245b7a08..4cd807dda37 100644 --- a/src/ert/gui/simulation/single_test_run_panel.py +++ b/src/ert/gui/simulation/single_test_run_panel.py @@ -4,7 +4,7 @@ from qtpy.QtWidgets import QFormLayout from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.copyablelabel import CopyableLabel +from ert.gui.ertwidgets import CopyableLabel from ert.mode_definitions import TEST_RUN_MODE from ert.run_models import SingleTestRun diff --git a/src/ert/gui/suggestor/_suggestor_message.py b/src/ert/gui/suggestor/_suggestor_message.py index cc486961322..c8f8e0bb1bf 100644 --- a/src/ert/gui/suggestor/_suggestor_message.py +++ b/src/ert/gui/suggestor/_suggestor_message.py @@ -58,6 +58,10 @@ def __init__( self._icon = icon self._message = message.replace("<", "<").replace(">", ">") self._locations = locations + + if self._locations and not self._locations[0]: + self._locations.pop(0) + self._header = header self._text_color = text_color diff --git a/src/ert/gui/ertwidgets/summarypanel.py b/src/ert/gui/summarypanel.py similarity index 100% rename from src/ert/gui/ertwidgets/summarypanel.py rename to src/ert/gui/summarypanel.py diff --git a/src/ert/gui/tools/export/export_tool.py b/src/ert/gui/tools/export/export_tool.py index 4650af8a8d0..cd68a621021 100644 --- a/src/ert/gui/tools/export/export_tool.py +++ b/src/ert/gui/tools/export/export_tool.py @@ -11,7 +11,7 @@ from ert.config import ErtConfig from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.closabledialog import ClosableDialog +from ert.gui.ertwidgets import ClosableDialog from ert.gui.tools import Tool from ert.gui.tools.export import ExportPanel diff --git a/src/ert/gui/tools/load_results/load_results_panel.py b/src/ert/gui/tools/load_results/load_results_panel.py index 7c1e31c49a8..f7e4d9cf62a 100644 --- a/src/ert/gui/tools/load_results/load_results_panel.py +++ b/src/ert/gui/tools/load_results/load_results_panel.py @@ -3,11 +3,13 @@ from qtpy.QtWidgets import QFormLayout, QMessageBox, QTextEdit, QWidget from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.ensembleselector import EnsembleSelector -from ert.gui.ertwidgets.message_box import ErtMessageBox -from ert.gui.ertwidgets.models.activerealizationsmodel import ActiveRealizationsModel -from ert.gui.ertwidgets.models.valuemodel import ValueModel -from ert.gui.ertwidgets.stringbox import StringBox +from ert.gui.ertwidgets import ( + ActiveRealizationsModel, + EnsembleSelector, + ErtMessageBox, + StringBox, + ValueModel, +) from ert.libres_facade import LibresFacade from ert.run_models.base_run_model import captured_logs from ert.validation import IntegerArgument, RangeStringArgument diff --git a/src/ert/gui/tools/load_results/load_results_tool.py b/src/ert/gui/tools/load_results/load_results_tool.py index 4a9a9bbd560..1f0aada1db1 100644 --- a/src/ert/gui/tools/load_results/load_results_tool.py +++ b/src/ert/gui/tools/load_results/load_results_tool.py @@ -4,8 +4,7 @@ from qtpy.QtWidgets import QPushButton from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import showWaitCursorWhileWaiting -from ert.gui.ertwidgets.closabledialog import ClosableDialog +from ert.gui.ertwidgets import ClosableDialog, showWaitCursorWhileWaiting from ert.gui.tools import Tool from ert.gui.tools.load_results import LoadResultsPanel from ert.libres_facade import LibresFacade diff --git a/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py b/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py index afd777a722b..17fb8acf994 100644 --- a/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py +++ b/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py @@ -13,12 +13,15 @@ ) from ert.enkf_main import sample_prior -from ert.gui.ertwidgets import showWaitCursorWhileWaiting -from ert.gui.ertwidgets.checklist import CheckList -from ert.gui.ertwidgets.ensembleselector import EnsembleSelector -from ert.gui.ertwidgets.models.selectable_list_model import SelectableListModel -from ert.gui.ertwidgets.storage_info_widget import StorageInfoWidget -from ert.gui.ertwidgets.storage_widget import StorageWidget +from ert.gui.ertwidgets import ( + CheckList, + EnsembleSelector, + SelectableListModel, + showWaitCursorWhileWaiting, +) + +from .storage_info_widget import StorageInfoWidget +from .storage_widget import StorageWidget if TYPE_CHECKING: from ert.config import ErtConfig diff --git a/src/ert/gui/ertwidgets/storage_info_widget.py b/src/ert/gui/tools/manage_experiments/storage_info_widget.py similarity index 100% rename from src/ert/gui/ertwidgets/storage_info_widget.py rename to src/ert/gui/tools/manage_experiments/storage_info_widget.py diff --git a/src/ert/gui/ertwidgets/models/storage_model.py b/src/ert/gui/tools/manage_experiments/storage_model.py similarity index 100% rename from src/ert/gui/ertwidgets/models/storage_model.py rename to src/ert/gui/tools/manage_experiments/storage_model.py diff --git a/src/ert/gui/ertwidgets/storage_widget.py b/src/ert/gui/tools/manage_experiments/storage_widget.py similarity index 83% rename from src/ert/gui/ertwidgets/storage_widget.py rename to src/ert/gui/tools/manage_experiments/storage_widget.py index d469d35e1b0..1be6f6351c8 100644 --- a/src/ert/gui/ertwidgets/storage_widget.py +++ b/src/ert/gui/tools/manage_experiments/storage_widget.py @@ -1,13 +1,19 @@ +from typing import Callable + from qtpy.QtCore import ( QAbstractItemModel, QItemSelectionModel, QModelIndex, + QSize, QSortFilterProxyModel, Qt, Signal, ) +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( + QHBoxLayout, QLineEdit, + QToolButton, QTreeView, QVBoxLayout, QWidget, @@ -16,14 +22,41 @@ from ert.config import ErtConfig from ert.gui.ertnotifier import ErtNotifier from ert.gui.ertwidgets.create_experiment_dialog import CreateExperimentDialog -from ert.gui.ertwidgets.ensemblelist import AddWidget -from ert.gui.ertwidgets.models.storage_model import ( +from ert.storage import Ensemble, Experiment + +from .storage_model import ( EnsembleModel, ExperimentModel, RealizationModel, StorageModel, ) -from ert.storage import Ensemble, Experiment + + +class AddWidget(QWidget): + """ + A widget with an add button. + Parameters + ---------- + addFunction: Callable to be connected to the add button. + """ + + def __init__(self, addFunction: Callable[[], None]) -> None: + super().__init__() + + self.addButton = QToolButton(self) + self.addButton.setIcon(QIcon("img:add_circle_outlined.svg")) + self.addButton.setIconSize(QSize(16, 16)) + self.addButton.clicked.connect(addFunction) + + self.removeButton = None + + self.buttonLayout = QHBoxLayout() + self.buttonLayout.setContentsMargins(0, 0, 0, 0) + self.buttonLayout.addStretch(1) + self.buttonLayout.addWidget(self.addButton) + self.buttonLayout.addSpacing(2) + + self.setLayout(self.buttonLayout) class _SortingProxyModel(QSortFilterProxyModel): diff --git a/src/ert/gui/tools/plot/color_chooser.py b/src/ert/gui/tools/plot/color_chooser.py index 24a9e476424..5e195794706 100644 --- a/src/ert/gui/tools/plot/color_chooser.py +++ b/src/ert/gui/tools/plot/color_chooser.py @@ -1,25 +1,55 @@ from typing import Optional, Tuple -from qtpy.QtCore import QRect, QSize, Signal +from qtpy.QtCore import QRect, QSize, Signal, Slot from qtpy.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent from qtpy.QtWidgets import QColorDialog, QFrame class ColorBox(QFrame): colorChanged = Signal(QColor) + mouseRelease = Signal() """A widget that shows a colored box""" - def __init__(self, color: QColor, size: int = 15) -> None: + def __init__(self, size: int = 15) -> None: QFrame.__init__(self) self.setFrameStyle(QFrame.Panel | QFrame.Sunken) self.setMaximumSize(QSize(size, size)) self.setMinimumSize(QSize(size, size)) self._tile_colors = [QColor(255, 255, 255), QColor(200, 200, 255)] + self._color: QColor = QColor(255, 255, 255) + + self.mouseRelease.connect(self.show_color_dialog) + self.colorChanged.connect(self.update_color) + + @Slot(QColor) + def update_color(self, color: QColor) -> None: self._color = color + self.update() + + @Slot() + def show_color_dialog(self) -> None: + color_dialog = QColorDialog(self._color, self) + color_dialog.setWindowTitle("Select color") + color_dialog.setOption(QColorDialog.ShowAlphaChannel) + color_dialog.accepted.connect( + lambda: self.colorChanged.emit(color_dialog.selectedColor()) + ) + color_dialog.open() - def paintEvent(self, a0: Optional[QPaintEvent]) -> None: + @property + def color(self) -> QColor: + return self._color + + @color.setter + def color(self, color: Tuple[str, float]) -> None: + new_color = QColor(color[0]) + new_color.setAlphaF(color[1]) + self._color = new_color + self.update() + + def paintEvent(self, event: Optional[QPaintEvent]) -> None: """Paints the box""" painter = QPainter(self) rect = self.contentsRect() @@ -37,25 +67,9 @@ def paintEvent(self, a0: Optional[QPaintEvent]) -> None: painter.restore() painter.fillRect(rect, self._color) - QFrame.paintEvent(self, a0) - - def mouseReleaseEvent(self, a0: Optional[QMouseEvent]) -> None: - color = QColorDialog.getColor( - self._color, self, "Select color", QColorDialog.ShowAlphaChannel - ) - - if color.isValid(): - self._color = color - self.update() - self.colorChanged.emit(self._color) - - @property - def color(self) -> QColor: - return self._color + QFrame.paintEvent(self, event) - @color.setter - def color(self, color: Tuple[str, float]) -> None: - new_color = QColor(color[0]) - new_color.setAlphaF(color[1]) - self._color = new_color - self.update() + def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: + if event: + self.mouseRelease.emit() + return super().mouseReleaseEvent(event) diff --git a/src/ert/gui/tools/plot/customize/style_customization_view.py b/src/ert/gui/tools/plot/customize/style_customization_view.py index 65f611dc2f4..117d65ce173 100644 --- a/src/ert/gui/tools/plot/customize/style_customization_view.py +++ b/src/ert/gui/tools/plot/customize/style_customization_view.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING, List, Tuple -from qtpy.QtGui import QColor from qtpy.QtWidgets import QHBoxLayout from ert.gui.tools.plot import ColorBox @@ -76,7 +75,7 @@ def setObservationsColor(self, color_tuple: Tuple[str, float]) -> None: @staticmethod def createColorBox(name: str) -> ColorBox: - color_box = ColorBox(QColor(255, 255, 255), 20) + color_box = ColorBox(size=20) color_box.setToolTip(name) return color_box diff --git a/src/ert/gui/tools/plot/data_type_keys_widget.py b/src/ert/gui/tools/plot/data_type_keys_widget.py index f07174d5b2c..d7040d30b84 100644 --- a/src/ert/gui/tools/plot/data_type_keys_widget.py +++ b/src/ert/gui/tools/plot/data_type_keys_widget.py @@ -1,10 +1,17 @@ from typing import Dict, List, Optional -from qtpy.QtCore import Signal -from qtpy.QtGui import QIcon -from qtpy.QtWidgets import QHBoxLayout, QListView, QToolButton, QVBoxLayout, QWidget - -from ert.gui.ertwidgets import Legend, SearchBox +from qtpy.QtCore import QSize, Signal +from qtpy.QtGui import QColor, QIcon, QPainter, QPaintEvent +from qtpy.QtWidgets import ( + QHBoxLayout, + QLabel, + QListView, + QToolButton, + QVBoxLayout, + QWidget, +) + +from ert.gui.ertwidgets import SearchBox from ert.gui.tools.plot.plot_api import PlotApiKeyDefinition from .data_type_keys_list_model import DataTypeKeysListModel @@ -12,6 +19,55 @@ from .filter_popup import FilterPopup +class _LegendMarker(QWidget): + """A widget that shows a colored box""" + + def __init__(self, color: QColor): + QWidget.__init__(self) + + self.setMaximumSize(QSize(12, 12)) + self.setMinimumSize(QSize(12, 12)) + + self.color = color + + def paintEvent(self, a0: Optional[QPaintEvent]) -> None: + painter = QPainter(self) + + rect = self.contentsRect() + rect.setWidth(rect.width() - 1) + rect.setHeight(rect.height() - 1) + painter.drawRect(rect) + + rect.setX(rect.x() + 1) + rect.setY(rect.y() + 1) + painter.fillRect(rect, self.color) + + +class _Legend(QWidget): + """Combines a _LegendMarker with a label""" + + def __init__(self, legend: Optional[str], color: QColor): + QWidget.__init__(self) + + self.setMinimumWidth(140) + self.setMaximumHeight(25) + + self.legend = legend + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + + self.legend_marker = _LegendMarker(color) + self.legend_marker.setToolTip(legend) + + layout.addWidget(self.legend_marker) + self.legend_label = QLabel(legend) + layout.addWidget(self.legend_label) + layout.addStretch() + + self.setLayout(layout) + + class DataTypeKeysWidget(QWidget): dataTypeKeySelected = Signal() @@ -49,7 +105,7 @@ def __init__(self, key_defs: List[PlotApiKeyDefinition]): layout.addStretch() layout.addWidget( - Legend("Observations available", DataTypeKeysListModel.HAS_OBSERVATIONS) + _Legend("Observations available", DataTypeKeysListModel.HAS_OBSERVATIONS) ) self.setLayout(layout) diff --git a/src/ert/gui/tools/plot/plot_api.py b/src/ert/gui/tools/plot/plot_api.py index 6cc35d1982e..e920121f8bf 100644 --- a/src/ert/gui/tools/plot/plot_api.py +++ b/src/ert/gui/tools/plot/plot_api.py @@ -1,4 +1,3 @@ -import base64 import io import logging from dataclasses import dataclass @@ -7,6 +6,8 @@ from typing import Any, Dict, List, NamedTuple, Optional import httpx +import numpy as np +import numpy.typing as npt import pandas as pd from pandas.errors import ParserError @@ -116,10 +117,16 @@ def all_data_type_keys(self) -> List[PlotApiKeyDefinition]: self._check_response(response) for key, value in response.json().items(): assert isinstance(key, str) + + has_observation = value["has_observations"] + k = all_keys.get(key) + if k and k.observations: + has_observation = True + all_keys[key] = PlotApiKeyDefinition( key=key, index_type="VALUE", - observations=value["has_observations"], + observations=has_observation, dimensionality=2, metadata=value["userdata"], log_scale=key.startswith("LOG10_"), @@ -175,43 +182,49 @@ def data_for_key(self, ensemble_name: str, key: str) -> pd.DataFrame: except ValueError: return df - def observations_for_key(self, ensemble_name: str, key: str) -> pd.DataFrame: + def observations_for_key(self, ensemble_names: List[str], key: str) -> pd.DataFrame: """Returns a pandas DataFrame with the datapoints for a given observation key - for a given ensemble. The row index is the realization number, and the column index + for a given ensembles. The row index is the realization number, and the column index is a multi-index with (obs_key, index/date, obs_index), where index/date is used to relate the observation to the data point it relates to, and obs_index is the index for the observation itself""" + all_observations = pd.DataFrame() + for ensemble_name in ensemble_names: + ensemble = self._get_ensemble(ensemble_name) + if not ensemble: + continue - ensemble = self._get_ensemble(ensemble_name) - if not ensemble: - return pd.DataFrame() - - with StorageService.session() as client: - response = client.get( - f"/ensembles/{ensemble.id}/records/{key}/observations", - timeout=self._timeout, - ) - self._check_response(response) - if not response.json(): - return pd.DataFrame() - try: - obs = response.json()[0] - except (KeyError, IndexError, JSONDecodeError) as e: - raise httpx.RequestError( - f"Observation schema might have changed key={key}, ensemble_name={ensemble_name}, e={e}" - ) from e - try: - int(obs["x_axis"][0]) - key_index = [int(v) for v in obs["x_axis"]] - except ValueError: - key_index = [pd.Timestamp(v) for v in obs["x_axis"]] + with StorageService.session() as client: + response = client.get( + f"/ensembles/{ensemble.id}/records/{key}/observations", + timeout=self._timeout, + ) + self._check_response(response) + if not response.json(): + continue + try: + obs = response.json()[0] + except (KeyError, IndexError, JSONDecodeError) as e: + raise httpx.RequestError( + f"Observation schema might have changed key={key}, ensemble_name={ensemble_name}, e={e}" + ) from e + try: + int(obs["x_axis"][0]) + key_index = [int(v) for v in obs["x_axis"]] + except ValueError: + key_index = [pd.Timestamp(v) for v in obs["x_axis"]] + + data_struct = { + "STD": obs["errors"], + "OBS": obs["values"], + "key_index": key_index, + } + + all_observations = pd.concat( + [all_observations, pd.DataFrame(data_struct)] + ) - data_struct = { - "STD": obs["errors"], - "OBS": obs["values"], - "key_index": key_index, - } - return pd.DataFrame(data_struct).T + return all_observations.T def history_data(self, key: str, ensembles: Optional[List[str]]) -> pd.DataFrame: """Returns a pandas DataFrame with the data points for the history for a @@ -239,17 +252,23 @@ def history_data(self, key: str, ensembles: Optional[List[str]]) -> pd.DataFrame return pd.DataFrame() - def std_dev_for_parameter(self, key: str, ensemble_name: str, z: int) -> bytes: + def std_dev_for_parameter( + self, key: str, ensemble_name: str, z: int + ) -> npt.NDArray[np.float32]: ensemble = self._get_ensemble(ensemble_name) if not ensemble: - return bytearray() + return np.array([]) with StorageService.session() as client: response = client.get( - f"/ensembles/{ensemble.id}/records/{key}/std_dev?z={z}", + f"/ensembles/{ensemble.id}/records/{key}/std_dev", + params={"z": z}, timeout=self._timeout, ) self._check_response(response) - if not response.json()["image"]: - return bytearray() - return base64.b64decode(response.json()["image"]) + + if response.status_code == 200: + # Deserialize the numpy array + return np.load(io.BytesIO(response.content)) + else: + return np.array([]) diff --git a/src/ert/gui/tools/plot/plot_widget.py b/src/ert/gui/tools/plot/plot_widget.py index f3053fae9c3..1abc79cc400 100644 --- a/src/ert/gui/tools/plot/plot_widget.py +++ b/src/ert/gui/tools/plot/plot_widget.py @@ -2,6 +2,8 @@ import traceback from typing import TYPE_CHECKING, Dict, Optional, Union +import numpy as np +import numpy.typing as npt import pandas as pd from matplotlib.backends.backend_qt5agg import ( # type: ignore FigureCanvas, @@ -143,7 +145,7 @@ def updatePlot( plot_context: "PlotContext", ensemble_to_data_map: Dict[EnsembleObject, pd.DataFrame], observations: pd.DataFrame, - std_dev_images: Dict[str, bytes], + std_dev_images: Dict[str, npt.NDArray[np.float32]], ) -> None: self.resetPlot() try: diff --git a/src/ert/gui/tools/plot/plot_window.py b/src/ert/gui/tools/plot/plot_window.py index d12d3e060b3..4537957f09e 100644 --- a/src/ert/gui/tools/plot/plot_window.py +++ b/src/ert/gui/tools/plot/plot_window.py @@ -1,12 +1,13 @@ import logging import time -from typing import Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +import numpy as np import pandas as pd from httpx import RequestError from pandas import DataFrame from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QDockWidget, QMainWindow, QMessageBox, QTabWidget, QWidget +from qtpy.QtWidgets import QDockWidget, QMainWindow, QTabWidget, QWidget from ert.gui.ertwidgets import showWaitCursorWhileWaiting from ert.gui.plottery import PlotConfig, PlotContext @@ -35,62 +36,56 @@ logger = logging.getLogger(__name__) -from qtpy.QtCore import QTimer -from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, - QPushButton, QTextEdit, QVBoxLayout, ) +from ert.gui.ertwidgets import CopyButton -def open_error_dialog(title: str, content: str) -> None: +if TYPE_CHECKING: + import numpy.typing as npt + + +class _CopyButton(CopyButton): + def __init__(self, text_edit: QTextEdit) -> None: + super().__init__() + self.text_edit = text_edit + + def copy(self) -> None: + self.copy_text(self.text_edit.toPlainText()) + + +def create_error_dialog(title: str, content: str) -> QDialog: qd = QDialog() qd.setModal(True) qd.setSizeGripEnabled(True) layout = QVBoxLayout() top_layout = QHBoxLayout() top_layout.addWidget(QLabel(title)) - copy_button = QPushButton("") - copy_button.setMinimumHeight(35) - copy_button.setMaximumWidth(100) - top_layout.addWidget(copy_button) - - restore_timer = QTimer() - - def restore_text() -> None: - copy_button.setIcon(QIcon("img:copy.svg")) - - restore_text() - - def copy_text() -> None: - clipboard = QApplication.clipboard() - if clipboard: - clipboard.setText(text) - else: - QMessageBox.critical( - None, - "Error", - "Cannot copy text to clipboard because your system does not have a clipboard", - QMessageBox.Ok, - ) - copy_button.setIcon(QIcon("img:check.svg")) - - restore_timer.start(1000) - - copy_button.clicked.connect(copy_text) - restore_timer.timeout.connect(restore_text) - layout.addLayout(top_layout) text = QTextEdit() text.setText(content) text.setReadOnly(True) + + copy_button = _CopyButton(text) + copy_button.setObjectName("copy_button") + top_layout.addWidget(copy_button) + top_layout.addStretch(-1) + + layout.addLayout(top_layout) layout.addWidget(text) + qd.setLayout(layout) + return qd + + +def open_error_dialog(title: str, content: str) -> None: + qd = create_error_dialog(title, content) QApplication.restoreOverrideCursor() qd.exec() @@ -203,13 +198,13 @@ def updatePlot(self, layer: Optional[int] = None) -> None: if key_def.observations and selected_ensembles: try: observations = self._api.observations_for_key( - selected_ensembles[0].name, key + [ensembles.name for ensembles in selected_ensembles], key ) except (RequestError, TimeoutError) as e: logger.exception(e) open_error_dialog("Request failed", f"{e}") - std_dev_images: Dict[str, bytes] = {} + std_dev_images: Dict[str, npt.NDArray[np.float32]] = {} if "FIELD" in key_def.metadata["data_origin"]: plot_widget.showLayerWidget.emit(True) diff --git a/src/ert/gui/tools/run_analysis/run_analysis_panel.py b/src/ert/gui/tools/run_analysis/run_analysis_panel.py index f74a2f7b9ed..14675fc1150 100644 --- a/src/ert/gui/tools/run_analysis/run_analysis_panel.py +++ b/src/ert/gui/tools/run_analysis/run_analysis_panel.py @@ -5,8 +5,7 @@ from qtpy.QtWidgets import QFormLayout, QLineEdit, QWidget from ert.config import AnalysisModule -from ert.gui.ertwidgets.analysismoduleedit import AnalysisModuleEdit -from ert.gui.ertwidgets.ensembleselector import EnsembleSelector +from ert.gui.ertwidgets import AnalysisModuleEdit, EnsembleSelector if TYPE_CHECKING: from ert.gui.ertnotifier import ErtNotifier diff --git a/src/ert/gui/tools/workflows/workflows_tool.py b/src/ert/gui/tools/workflows/workflows_tool.py index d42810a4b33..5cc87f44d5f 100644 --- a/src/ert/gui/tools/workflows/workflows_tool.py +++ b/src/ert/gui/tools/workflows/workflows_tool.py @@ -4,7 +4,7 @@ from qtpy.QtGui import QIcon -from ert.gui.ertwidgets.closabledialog import ClosableDialog +from ert.gui.ertwidgets import ClosableDialog from ert.gui.tools import Tool from ert.gui.tools.workflows import RunWorkflowWidget diff --git a/src/ert/resources/workflows/jobs/internal-gui/scripts/csv_export.py b/src/ert/resources/workflows/jobs/internal-gui/scripts/csv_export.py index 6ed720f6752..1c87315c186 100644 --- a/src/ert/resources/workflows/jobs/internal-gui/scripts/csv_export.py +++ b/src/ert/resources/workflows/jobs/internal-gui/scripts/csv_export.py @@ -161,10 +161,7 @@ def run( return export_info def getArguments(self, parent, ert_config, storage): - from ert.gui.ertwidgets.customdialog import CustomDialog - from ert.gui.ertwidgets.listeditbox import ListEditBox - from ert.gui.ertwidgets.models.path_model import PathModel - from ert.gui.ertwidgets.pathchooser import PathChooser + from ert.gui.ertwidgets import CustomDialog, ListEditBox, PathChooser, PathModel description = "The CSV export requires some information before it starts:" dialog = CustomDialog("CSV Export", description, parent) diff --git a/tests/unit_tests/gui/conftest.py b/tests/unit_tests/gui/conftest.py index 150664b1d4f..86c212a0f4c 100644 --- a/tests/unit_tests/gui/conftest.py +++ b/tests/unit_tests/gui/conftest.py @@ -36,9 +36,7 @@ ) from ert.gui.ertwidgets import ClosableDialog from ert.gui.ertwidgets.create_experiment_dialog import CreateExperimentDialog -from ert.gui.ertwidgets.ensemblelist import AddWidget from ert.gui.ertwidgets.ensembleselector import EnsembleSelector -from ert.gui.ertwidgets.storage_widget import StorageWidget from ert.gui.main import ErtMainWindow, GUILogHandler, _setup_main_window from ert.gui.simulation.experiment_panel import ExperimentPanel from ert.gui.simulation.run_dialog import RunDialog @@ -47,6 +45,7 @@ from ert.gui.tools.manage_experiments.manage_experiments_tool import ( ManageExperimentsTool, ) +from ert.gui.tools.manage_experiments.storage_widget import AddWidget, StorageWidget from ert.run_models import EnsembleExperiment, MultipleDataAssimilation from ert.services import StorageService from ert.storage import Storage, open_storage diff --git a/tests/unit_tests/gui/ertwidgets/test_summarypanel.py b/tests/unit_tests/gui/ertwidgets/test_summarypanel.py index cb2869f821d..176c307840f 100644 --- a/tests/unit_tests/gui/ertwidgets/test_summarypanel.py +++ b/tests/unit_tests/gui/ertwidgets/test_summarypanel.py @@ -2,7 +2,7 @@ import pytest -from ert.gui.ertwidgets.summarypanel import SummaryPanel +from ert.gui.summarypanel import SummaryPanel @pytest.mark.parametrize( diff --git a/tests/unit_tests/gui/plottery/baseline/test_that_all_snake_oil_visualisations_matches_snapshot_plot_figure3.png b/tests/unit_tests/gui/plottery/baseline/test_that_all_snake_oil_visualisations_matches_snapshot_plot_figure3.png new file mode 100644 index 00000000000..e02a5eb668b Binary files /dev/null and b/tests/unit_tests/gui/plottery/baseline/test_that_all_snake_oil_visualisations_matches_snapshot_plot_figure3.png differ diff --git a/tests/unit_tests/gui/test_full_manual_update_workflow.py b/tests/unit_tests/gui/test_full_manual_update_workflow.py index 61434738aca..b0526f851e1 100644 --- a/tests/unit_tests/gui/test_full_manual_update_workflow.py +++ b/tests/unit_tests/gui/test_full_manual_update_workflow.py @@ -13,11 +13,11 @@ ) from ert.data import MeasuredData -from ert.gui.ertwidgets.storage_widget import StorageWidget from ert.gui.simulation.evaluate_ensemble_panel import EvaluateEnsemblePanel from ert.gui.simulation.experiment_panel import ExperimentPanel from ert.gui.simulation.run_dialog import RunDialog from ert.gui.tools.manage_experiments import ManageExperimentsTool +from ert.gui.tools.manage_experiments.storage_widget import StorageWidget from ert.run_models.evaluate_ensemble import EvaluateEnsemble from ert.validation import rangestring_to_mask diff --git a/tests/unit_tests/gui/test_main_window.py b/tests/unit_tests/gui/test_main_window.py index c7ef7b48f76..25ae61357a8 100644 --- a/tests/unit_tests/gui/test_main_window.py +++ b/tests/unit_tests/gui/test_main_window.py @@ -27,11 +27,9 @@ from ert.gui.ertwidgets.analysismodulevariablespanel import AnalysisModuleVariablesPanel from ert.gui.ertwidgets.create_experiment_dialog import CreateExperimentDialog from ert.gui.ertwidgets.customdialog import CustomDialog -from ert.gui.ertwidgets.ensemblelist import AddWidget from ert.gui.ertwidgets.ensembleselector import EnsembleSelector from ert.gui.ertwidgets.listeditbox import ListEditBox from ert.gui.ertwidgets.pathchooser import PathChooser -from ert.gui.ertwidgets.storage_widget import StorageWidget from ert.gui.main import ErtMainWindow, GUILogHandler, _setup_main_window from ert.gui.simulation.experiment_panel import ExperimentPanel from ert.gui.simulation.run_dialog import RunDialog @@ -39,6 +37,7 @@ from ert.gui.suggestor._suggestor_message import SuggestorMessage from ert.gui.tools.event_viewer import add_gui_log_handler from ert.gui.tools.manage_experiments import ManageExperimentsTool +from ert.gui.tools.manage_experiments.storage_widget import AddWidget, StorageWidget from ert.gui.tools.plot.data_type_keys_widget import DataTypeKeysWidget from ert.gui.tools.plot.plot_ensemble_selection_widget import ( EnsembleSelectListWidget, diff --git a/tests/unit_tests/gui/test_rft_export_plugin.py b/tests/unit_tests/gui/test_rft_export_plugin.py index 0da604cae38..398e5e7617c 100644 --- a/tests/unit_tests/gui/test_rft_export_plugin.py +++ b/tests/unit_tests/gui/test_rft_export_plugin.py @@ -8,9 +8,7 @@ from qtpy.QtWidgets import QMessageBox from ert.config import ErtConfig -from ert.gui.ertwidgets.customdialog import CustomDialog -from ert.gui.ertwidgets.listeditbox import ListEditBox -from ert.gui.ertwidgets.pathchooser import PathChooser +from ert.gui.ertwidgets import CustomDialog, ListEditBox, PathChooser from ert.gui.main import GUILogHandler, _setup_main_window from ert.services import StorageService from ert.storage import open_storage diff --git a/tests/unit_tests/gui/tools/plot/test_plot_api.py b/tests/unit_tests/gui/tools/plot/test_plot_api.py index ebc7f435973..864d07ecfbc 100644 --- a/tests/unit_tests/gui/tools/plot/test_plot_api.py +++ b/tests/unit_tests/gui/tools/plot/test_plot_api.py @@ -79,7 +79,7 @@ def test_can_load_data_and_observations(api): for key, value in keys.items(): observations = value["observations"] if observations: - obs_data = api.observations_for_key(ensemble_name, key) + obs_data = api.observations_for_key([ensemble_name], key) assert not obs_data.empty data = api.data_for_key(ensemble_name, key) assert not data.empty @@ -131,7 +131,7 @@ def test_plot_api_request_errors_all_data_type_keys(api, mocker): def test_plot_api_request_errors(api): ensemble_name = "default_0" with pytest.raises(httpx.RequestError): - api.observations_for_key(ensemble_name, "should_not_be_there") + api.observations_for_key([ensemble_name], "should_not_be_there") with pytest.raises(httpx.RequestError): api.data_for_key(ensemble_name, "should_not_be_there") diff --git a/tests/unit_tests/gui/tools/plot/test_plot_window.py b/tests/unit_tests/gui/tools/plot/test_plot_window.py new file mode 100644 index 00000000000..4191704c896 --- /dev/null +++ b/tests/unit_tests/gui/tools/plot/test_plot_window.py @@ -0,0 +1,18 @@ +from pytestqt.qtbot import QtBot +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QApplication, + QPushButton, +) + +from ert.gui.tools.plot.plot_window import create_error_dialog + + +def test_pressing_copy_button_in_error_dialog(qtbot: QtBot): + qd = create_error_dialog("hello", "world") + qtbot.addWidget(qd) + + qtbot.mouseClick( + qd.findChild(QPushButton, name="copy_button"), Qt.MouseButton.LeftButton + ) + assert QApplication.clipboard().text() == "world" diff --git a/tests/unit_tests/gui/tools/test_manage_experiments_tool.py b/tests/unit_tests/gui/tools/test_manage_experiments_tool.py index 50356fbfff8..eedcc40b16d 100644 --- a/tests/unit_tests/gui/tools/test_manage_experiments_tool.py +++ b/tests/unit_tests/gui/tools/test_manage_experiments_tool.py @@ -4,14 +4,14 @@ from ert.config import ErtConfig from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets.storage_info_widget import ( +from ert.gui.tools.manage_experiments import ManageExperimentsPanel +from ert.gui.tools.manage_experiments.storage_info_widget import ( _EnsembleWidget, _EnsembleWidgetTabs, _ExperimentWidget, _WidgetType, ) -from ert.gui.ertwidgets.storage_widget import StorageWidget -from ert.gui.tools.manage_experiments import ManageExperimentsPanel +from ert.gui.tools.manage_experiments.storage_widget import StorageWidget from ert.storage import Storage from ert.storage.realization_storage_state import RealizationStorageState diff --git a/tests/unit_tests/gui/tools/test_workflow_tool.py b/tests/unit_tests/gui/tools/test_workflow_tool.py index bfd59d47d1b..f2a794b00ac 100644 --- a/tests/unit_tests/gui/tools/test_workflow_tool.py +++ b/tests/unit_tests/gui/tools/test_workflow_tool.py @@ -8,7 +8,7 @@ from qtpy.QtCore import Qt, QTimer from ert.config import ErtConfig -from ert.gui.ertwidgets.closabledialog import ClosableDialog +from ert.gui.ertwidgets import ClosableDialog from ert.gui.main import _setup_main_window from ert.gui.main_window import ErtMainWindow from ert.gui.tools.event_viewer import GUILogHandler