diff --git a/src/ert/dark_storage/endpoints/experiments.py b/src/ert/dark_storage/endpoints/experiments.py index d0f61fe03d6..f4d5e7e87fa 100644 --- a/src/ert/dark_storage/endpoints/experiments.py +++ b/src/ert/dark_storage/endpoints/experiments.py @@ -22,11 +22,14 @@ def get_experiments( js.ExperimentOut( id=experiment.id, name=experiment.name, - ensemble_ids=[ens.id for ens in experiment.ensembles], + ensemble_ids=[ + ens.id for ens in experiment.ensembles if experiment.is_valid() + ], priors=create_priors(experiment), userdata={}, ) for experiment in storage.experiments + if experiment.is_valid() ] diff --git a/src/ert/dark_storage/endpoints/records.py b/src/ert/dark_storage/endpoints/records.py index 923968a60e2..51d4f3dd5d3 100644 --- a/src/ert/dark_storage/endpoints/records.py +++ b/src/ert/dark_storage/endpoints/records.py @@ -124,8 +124,7 @@ def get_ensemble_responses( response_names_with_observations = set() observations = ensemble.experiment.observations - - if len(ensemble.has_data()) == 0: + if not ensemble.experiment.is_valid() or len(ensemble.has_data()) == 0: return {} for ( diff --git a/src/ert/gui/ertnotifier.py b/src/ert/gui/ertnotifier.py index 274674c8329..2c39051e724 100644 --- a/src/ert/gui/ertnotifier.py +++ b/src/ert/gui/ertnotifier.py @@ -63,3 +63,10 @@ def set_current_ensemble(self, ensemble: Ensemble | None = None) -> None: @Slot(bool) def set_is_simulation_running(self, is_running: bool) -> None: self._is_simulation_running = is_running + self.refresh() + + def refresh(self) -> None: + if self._storage is None: + return + self._storage.refresh() + self.storage_changed.emit(self._storage) diff --git a/src/ert/gui/ertwidgets/ensembleselector.py b/src/ert/gui/ertwidgets/ensembleselector.py index 62926ddf332..ab82d4df44b 100644 --- a/src/ert/gui/ertwidgets/ensembleselector.py +++ b/src/ert/gui/ertwidgets/ensembleselector.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QStandardItemModel from qtpy.QtWidgets import QComboBox from ert.gui.ertnotifier import ErtNotifier @@ -22,6 +23,7 @@ def __init__( update_ert: bool = True, show_only_undefined: bool = False, show_only_no_children: bool = False, + show_only_with_valid_experiment: bool = False, ): super().__init__() self.notifier = notifier @@ -36,6 +38,7 @@ def __init__( # if the ensemble has not been used in an update, as that would # invalidate the result self._show_only_no_children = show_only_no_children + self._show_only_with_valid_experiment = show_only_with_valid_experiment self.setSizeAdjustPolicy(QComboBox.AdjustToContents) self.setEnabled(False) @@ -68,9 +71,28 @@ def populate(self) -> None: self.setEnabled(True) for ensemble in self._ensemble_list(): + model = self.model() + assert isinstance(model, QStandardItemModel) + assert model is not None self.addItem( f"{ensemble.experiment.name} : {ensemble.name}", userData=ensemble ) + if ( + self._show_only_with_valid_experiment + and not ensemble.experiment.is_valid() + ): + index = self.count() - 1 + model_item = model.item(index) + assert model_item is not None + new_flags = ( + model_item.flags() + & ~Qt.ItemFlags(Qt.ItemFlag.ItemIsEnabled) + & ~Qt.ItemFlags(Qt.ItemFlag.ItemIsSelectable) + ) + model_item.setFlags(new_flags) + self.setItemData( + index, "This ensemble is invalid", Qt.ItemDataRole.ToolTipRole + ) current_index = self.findData( self.notifier.current_ensemble, Qt.ItemDataRole.UserRole @@ -95,6 +117,8 @@ def _ensemble_list(self) -> Iterable[Ensemble]: else: ensembles = self.notifier.storage.ensembles ensemble_list = list(ensembles) + if self._show_only_with_valid_experiment: + ensemble_list = [ens for ens in ensemble_list if ens.experiment.is_valid()] if self._show_only_no_children: parents = [ ens.parent for ens in self.notifier.storage.ensembles if ens.parent diff --git a/src/ert/gui/main_window.py b/src/ert/gui/main_window.py index da861059193..46ec5d851a5 100644 --- a/src/ert/gui/main_window.py +++ b/src/ert/gui/main_window.py @@ -5,7 +5,7 @@ import webbrowser from qtpy.QtCore import QCoreApplication, QEvent, QSize, Qt, Signal, Slot -from qtpy.QtGui import QCloseEvent, QCursor, QIcon, QMouseEvent +from qtpy.QtGui import QCloseEvent, QCursor, QFocusEvent, QIcon, QMouseEvent from qtpy.QtWidgets import ( QAction, QButtonGroup, @@ -105,7 +105,8 @@ def __init__( self.side_frame = QFrame(self) self.button_group = QButtonGroup(self.side_frame) self._external_plot_windows: list[PlotWindow] = [] - + self.setFocusPolicy(Qt.FocusPolicy.TabFocus) + self.central_widget.setFocusPolicy(Qt.FocusPolicy.TabFocus) if self.is_dark_mode(): self.side_frame.setStyleSheet("background-color: rgb(64, 64, 64);") else: @@ -154,6 +155,7 @@ def right_clicked(self) -> None: def select_central_widget(self) -> None: actor = self.sender() if actor: + self.notifier.refresh() index_name = actor.property("index") for widget in self.central_panels_map.values(): @@ -364,3 +366,7 @@ def closeEvent(self, closeEvent: QCloseEvent | None) -> None: def __showAboutMessage(self) -> None: diag = AboutDialog(self) diag.show() + + def focusInEvent(self, event: QFocusEvent) -> None: + self.notifier.refresh() + QMainWindow.focusInEvent(self, event) diff --git a/src/ert/gui/model/real_list.py b/src/ert/gui/model/real_list.py index eddc60a8f51..48c02415aab 100644 --- a/src/ert/gui/model/real_list.py +++ b/src/ert/gui/model/real_list.py @@ -1,4 +1,4 @@ -from typing import overload +from typing import cast, overload from qtpy.QtCore import ( QAbstractItemModel, @@ -9,7 +9,12 @@ ) from typing_extensions import override -from ert.gui.model.snapshot import IsEnsembleRole, IsRealizationRole, NodeRole +from ert.gui.model.snapshot import ( + IsEnsembleRole, + IsRealizationRole, + NodeRole, + SnapshotModel, +) class RealListModel(QAbstractProxyModel): @@ -67,7 +72,7 @@ def columnCount(self, parent: QModelIndex | None = None) -> int: def rowCount(self, parent: QModelIndex | None = None) -> int: parent = parent if parent else QModelIndex() if not parent.isValid(): - source_model = self.sourceModel() + source_model = cast(SnapshotModel, self.sourceModel()) assert source_model is not None iter_index = source_model.index(self._iter, 0, QModelIndex()) if iter_index.isValid(): diff --git a/src/ert/gui/simulation/evaluate_ensemble_panel.py b/src/ert/gui/simulation/evaluate_ensemble_panel.py index a81b40ad15c..ed578272c19 100644 --- a/src/ert/gui/simulation/evaluate_ensemble_panel.py +++ b/src/ert/gui/simulation/evaluate_ensemble_panel.py @@ -35,7 +35,9 @@ def __init__(self, ensemble_size: int, run_path: str, notifier: ErtNotifier): lab.setWordWrap(True) lab.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) layout.addRow(lab) - self._ensemble_selector = EnsembleSelector(notifier, show_only_no_children=True) + self._ensemble_selector = EnsembleSelector( + notifier, show_only_no_children=True, show_only_with_valid_experiment=True + ) layout.addRow("Ensemble:", self._ensemble_selector) runpath_label = CopyableLabel(text=run_path) layout.addRow("Runpath:", runpath_label) 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 4fe9d8b34bb..1781b6738d8 100644 --- a/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py +++ b/src/ert/gui/tools/manage_experiments/manage_experiments_panel.py @@ -47,7 +47,6 @@ def __init__(self, config: ErtConfig, notifier: ErtNotifier, ensemble_size: int) def _add_create_new_ensemble_tab(self) -> None: panel = QWidget() panel.setObjectName("create_new_ensemble_tab") - layout = QHBoxLayout() storage_widget = StorageWidget( self.notifier, self.ert_config, self.ensemble_size @@ -75,7 +74,11 @@ def _add_initialize_from_scratch_tab(self) -> None: ensemble_layout = QHBoxLayout() ensemble_label = QLabel("Target ensemble:") - ensemble_selector = EnsembleSelector(self.notifier, show_only_undefined=True) + ensemble_selector = EnsembleSelector( + self.notifier, + show_only_undefined=True, + show_only_with_valid_experiment=True, + ) ensemble_selector.setMinimumWidth(300) ensemble_layout.addWidget(ensemble_label) ensemble_layout.addWidget(ensemble_selector) diff --git a/src/ert/gui/tools/manage_experiments/storage_model.py b/src/ert/gui/tools/manage_experiments/storage_model.py index 54ac392adfa..421c6a7383f 100644 --- a/src/ert/gui/tools/manage_experiments/storage_model.py +++ b/src/ert/gui/tools/manage_experiments/storage_model.py @@ -63,6 +63,7 @@ def __init__(self, ensemble: Ensemble, parent: Any): self._id = ensemble.id self._start_time = ensemble.started_at self._children: list[RealizationModel] = [] + self._error = ensemble.experiment.error_message def add_realization(self, realization: RealizationModel) -> None: self._children.append(realization) @@ -75,7 +76,6 @@ def row(self) -> int: def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any: if not index.isValid(): return None - col = index.column() if role == Qt.ItemDataRole.DisplayRole: if col == _Column.NAME: @@ -83,17 +83,20 @@ def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any: if col == _Column.TIME: return humanize.naturaltime(self._start_time) elif role == Qt.ItemDataRole.ToolTipRole: + if self._error: + return self._error if col == _Column.TIME: return str(self._start_time) - return None -class ExperimentModel: - def __init__(self, experiment: Experiment, parent: Any): +class ExperimentModel(QAbstractItemModel): + def __init__(self, experiment: Experiment, parent: "StorageModel"): self._parent = parent self._id = experiment.id self._name = experiment.name + self._is_valid = experiment.is_valid() + self._error = experiment.error_message self._experiment_type = experiment.metadata.get("ensemble_type") self._children: list[EnsembleModel] = [] @@ -105,9 +108,7 @@ def row(self) -> int: return self._parent._children.index(self) return 0 - def data( - self, index: QModelIndex, role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole - ) -> Any: + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: if not index.isValid(): return None @@ -128,7 +129,9 @@ def data( qapp = QApplication.instance() assert isinstance(qapp, QApplication) return qapp.palette().mid() - + elif role == Qt.ItemDataRole.ToolTipRole: + if self._error: + return self._error return None @@ -211,9 +214,31 @@ def headerData( def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: if not index.isValid(): return None - return index.internalPointer().data(index, role) + @override + def flags(self, index: QModelIndex) -> Qt.ItemFlags: + default_flags = super().flags(index) + if not index.isValid(): + return default_flags + item = index.internalPointer() + if isinstance(item, ExperimentModel) and not item._is_valid: + new_flags = default_flags & ~Qt.ItemFlags(Qt.ItemFlag.ItemIsEnabled) + return Qt.ItemFlags(new_flags) + return default_flags + + @override + def hasChildren(self, parent: QModelIndex | None = None) -> bool: + if parent is None or not parent.isValid(): + return True + + flags = self.flags(parent) + # hide children if disabled + if not (flags & Qt.ItemFlag.ItemIsEnabled): + return False + + return super().hasChildren(parent) + @override def index( self, row: int, column: int, parent: QModelIndex | None = None diff --git a/src/ert/gui/tools/plot/plot_api.py b/src/ert/gui/tools/plot/plot_api.py index b76ca97051b..7e112e3fc28 100644 --- a/src/ert/gui/tools/plot/plot_api.py +++ b/src/ert/gui/tools/plot/plot_api.py @@ -112,7 +112,7 @@ def all_data_type_keys(self) -> list[PlotApiKeyDefinition]: f"/experiments/{experiment['id']}/ensembles", timeout=self._timeout ) self._check_response(response) - + print(f"{experiment=}") for ensemble in response.json(): response = client.get( f"/ensembles/{ensemble['id']}/responses", timeout=self._timeout diff --git a/src/ert/storage/local_ensemble.py b/src/ert/storage/local_ensemble.py index 5c10872b57f..231342200fc 100644 --- a/src/ert/storage/local_ensemble.py +++ b/src/ert/storage/local_ensemble.py @@ -92,6 +92,7 @@ def create_realization_dir(realization: int) -> Path: return self._path / f"realization-{realization}" self._realization_dir = create_realization_dir + self.has_valid_experiment: bool | None = None @classmethod def create( @@ -406,6 +407,11 @@ def get_ensemble_state(self) -> list[set[RealizationStorageState]]: states : list of RealizationStorageState list of realization states. """ + if not self.experiment.is_valid(): + logger.warning( + f"Could not get ensemble state for ensemble ({self.id}) due to invalid experiment ({self.experiment_id}): {self.experiment.error_message}" + ) + return [] response_configs = self.experiment.response_configuration diff --git a/src/ert/storage/local_experiment.py b/src/ert/storage/local_experiment.py index 39612949b10..efd28139472 100644 --- a/src/ert/storage/local_experiment.py +++ b/src/ert/storage/local_experiment.py @@ -75,6 +75,26 @@ def __init__( self._index = _Index.model_validate_json( (path / "index.json").read_text(encoding="utf-8") ) + self._validate_files() + + def _validate_files(self) -> None: + self.valid_parameters = (self._path / self._parameter_file).exists() + self.valid_responses = (self._path / self._responses_file).exists() + self.valid_metadata = (self._path / self._metadata_file).exists() + + def is_valid(self) -> bool: + return self.valid_parameters and self.valid_responses and self.valid_metadata + + @property + def error_message(self) -> str: + errors = [] + if not self.valid_parameters: + errors.append("Parameter file is missing") + if not self.valid_responses: + errors.append("Responses file is missing") + if not self.valid_metadata: + errors.append("Metadata file is missing") + return "\n".join(errors) @classmethod def create( diff --git a/src/ert/storage/local_storage.py b/src/ert/storage/local_storage.py index c8f9de3001b..5474c649799 100644 --- a/src/ert/storage/local_storage.py +++ b/src/ert/storage/local_storage.py @@ -226,11 +226,15 @@ def _load_ensembles(self) -> dict[UUID, LocalEnsemble]: } def _load_experiments(self) -> dict[UUID, LocalExperiment]: - experiment_ids = {ens.experiment_id for ens in self._ensembles.values()} - return { - exp_id: LocalExperiment(self, self._experiment_path(exp_id), self.mode) - for exp_id in experiment_ids - } + experiments = {} + for ens in self._ensembles.values(): + experiment = LocalExperiment( + self, self._experiment_path(ens.experiment_id), self.mode + ) + ens.has_valid_experiment = experiment.is_valid() + experiments[ens.experiment_id] = experiment + + return experiments def _ensemble_path(self, ensemble_id: UUID) -> Path: return self.path / self.ENSEMBLES_PATH / str(ensemble_id) diff --git a/tests/ert/ui_tests/gui/conftest.py b/tests/ert/ui_tests/gui/conftest.py index bfdddf77a97..1ae7ac1837e 100644 --- a/tests/ert/ui_tests/gui/conftest.py +++ b/tests/ert/ui_tests/gui/conftest.py @@ -394,6 +394,13 @@ def wait_for_child(gui, qtbot: QtBot, typ: type[V], timeout=5000, **kwargs) -> V return get_child(gui, typ, **kwargs) +def wait_for_children( + gui, qtbot: QtBot, typ: type[V], timeout=5000, **kwargs +) -> list[V]: + qtbot.waitUntil(lambda: gui.findChildren(typ) is not None, timeout=timeout) + return get_children(gui, typ, **kwargs) + + def get_child(gui: QWidget, typ: type[V], *args, **kwargs) -> V: child = gui.findChild(typ, *args, **kwargs) assert isinstance(child, typ) diff --git a/tests/ert/ui_tests/gui/test_main_window.py b/tests/ert/ui_tests/gui/test_main_window.py index 30806ef4a01..a41095a18d5 100644 --- a/tests/ert/ui_tests/gui/test_main_window.py +++ b/tests/ert/ui_tests/gui/test_main_window.py @@ -41,6 +41,7 @@ from ert.gui.tools.manage_experiments import ( ManageExperimentsPanel, ) +from ert.gui.tools.manage_experiments.storage_model import StorageModel 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 ( @@ -69,6 +70,7 @@ get_children, load_results_manually, wait_for_child, + wait_for_children, ) @@ -854,3 +856,84 @@ def wait_for_simulation_completed(): choice.trigger() find_and_check_selected("button_Start_simulation", False) find_and_check_selected("button_Simulation_status", True) + + +from pytestqt.qtbot import QtBot + + +def test_that_invalid_experiments_are_disabled(opened_main_window_poly, qtbot: QtBot): + gui: ErtMainWindow = opened_main_window_poly + + def find_and_click_button(button_name: str): + button = gui.findChild(QToolButton, button_name) + assert button + qtbot.mouseClick(button, Qt.LeftButton) + + def run_experiment(): + run_experiment_panel = wait_for_child(gui, qtbot, ExperimentPanel) + qtbot.wait_until(lambda: not run_experiment_panel.isHidden(), timeout=5000) + assert run_experiment_panel.run_button.isEnabled() + qtbot.mouseClick(run_experiment_panel.run_button, Qt.LeftButton) + + def wait_for_simulation_completed(): + run_dialogs = get_children(gui, RunDialog) + dialog = run_dialogs[-1] + qtbot.wait_until(lambda: not dialog.isHidden(), timeout=5000) + qtbot.wait_until(lambda: dialog.is_simulation_done() == True, timeout=15000) + + def check_manage_experiment_tab( + expected_number_of_valid_experiments: int, + expected_number_of_invalid_experiments: int, + ) -> None: + find_and_click_button("button_Manage_experiments") + experiments_panel = wait_for_child(gui, qtbot, ManageExperimentsPanel) + + storage_widget = get_child(experiments_panel, StorageWidget) + tree_view = get_child(storage_widget, QTreeView) + tree_view_model: StorageModel = tree_view.model() + + number_of_found_invalid_experiments = 0 + number_of_found_valid_experiments = 0 + for row in range(tree_view_model.rowCount()): + index = tree_view_model.index(row, 0) + entry_is_enabled = bool( + tree_view_model.flags(index) & Qt.ItemIsEnabled + ) # here we check if it is enabled + if entry_is_enabled: + number_of_found_valid_experiments += 1 + else: + number_of_found_invalid_experiments += 1 + assert ( + tree_view_model.data(index, Qt.ItemDataRole.ToolTipRole) + == "Responses file is missing" + ) + assert ( + number_of_found_invalid_experiments + == expected_number_of_invalid_experiments + ) + assert number_of_found_valid_experiments == expected_number_of_valid_experiments + + def check_plot_tool(expected_number_of_cases: int) -> None: + find_and_click_button("button_Create_plot") + # Due to the fact that we create new instances of PlotWindow on tab change, QtBot is defaulting to the first child + plot_window = wait_for_children(gui, qtbot, PlotWindow)[-1] + case_selection = get_child(plot_window, EnsembleSelectListWidget) + assert case_selection._ensemble_count == expected_number_of_cases + + find_and_click_button("button_Start_simulation") + run_experiment() + wait_for_simulation_completed() + + # make sure experiment is valid in manage experiments panel + check_manage_experiment_tab(1, 0) + check_plot_tool(expected_number_of_cases=1) + + # delete the responses.json of the experiment + experiment_id = os.listdir("./storage/experiments")[0] + os.remove(f"./storage/experiments/{experiment_id}/responses.json") + + # The experiment should still be there, but disabled + check_manage_experiment_tab(0, 1) + + find_and_click_button("button_Create_plot") + check_plot_tool(expected_number_of_cases=0)