diff --git a/src/ert/gui/ertwidgets/create_experiment_dialog.py b/src/ert/gui/ertwidgets/create_experiment_dialog.py index 9a5d8572e9a..2a743bba783 100644 --- a/src/ert/gui/ertwidgets/create_experiment_dialog.py +++ b/src/ert/gui/ertwidgets/create_experiment_dialog.py @@ -13,12 +13,17 @@ QWidget, ) +from ert.gui.ertnotifier import ErtNotifier +from ert.gui.ertwidgets import StringBox, TextModel +from ert.validation.range_string_argument import NotInStorage + class CreateExperimentDialog(QDialog): onDone = Signal(str, str) def __init__( self, + notifier: ErtNotifier, title: str = "Create new experiment", parent: Optional[QWidget] = None, ) -> None: @@ -30,8 +35,13 @@ def __init__( layout = QGridLayout() experiment_label = QLabel("Experiment name:") - self._experiment_edit = QLineEdit() - self._experiment_edit.setPlaceholderText("My experiment") + self._experiment_edit = StringBox( + TextModel(""), + placeholder_text="My experiment", + ) + self._experiment_edit.setValidator( + NotInStorage(notifier.storage, "experiments") + ) ensemble_label = QLabel("Ensemble name:") self._ensemble_edit = QLineEdit() diff --git a/src/ert/gui/ertwidgets/storage_widget.py b/src/ert/gui/ertwidgets/storage_widget.py index 90120ae291a..74bf88954a0 100644 --- a/src/ert/gui/ertwidgets/storage_widget.py +++ b/src/ert/gui/ertwidgets/storage_widget.py @@ -112,7 +112,7 @@ def _currentChanged(self, selected: QModelIndex, previous: QModelIndex) -> None: self.onSelectRealization.emit(ensemble, cls.realization) def _addItem(self) -> None: - create_experiment_dialog = CreateExperimentDialog(parent=self) + create_experiment_dialog = CreateExperimentDialog(self._notifier, parent=self) create_experiment_dialog.show() if create_experiment_dialog.exec_(): ensemble = self._notifier.storage.create_experiment( diff --git a/src/ert/gui/simulation/ensemble_experiment_panel.py b/src/ert/gui/simulation/ensemble_experiment_panel.py index 1b775733e4e..568cd1f3fb5 100644 --- a/src/ert/gui/simulation/ensemble_experiment_panel.py +++ b/src/ert/gui/simulation/ensemble_experiment_panel.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from qtpy import QtCore +from qtpy.QtCore import Slot from qtpy.QtWidgets import QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier @@ -35,14 +36,18 @@ def __init__(self, ensemble_size: int, run_path: str, notifier: ErtNotifier): lab.setAlignment(QtCore.Qt.AlignLeft) layout.addRow(lab) - self._name_field = StringBox( - TextModel(""), placeholder_text="ensemble-experiment" + self._experiment_name_field = StringBox( + TextModel(""), + placeholder_text=self.notifier.storage.get_unique_experiment_name( + ENSEMBLE_EXPERIMENT_MODE + ), ) - self._name_field.setMinimumWidth(250) - layout.addRow("Experiment name:", self._name_field) - self._name_field.setValidator( + self._experiment_name_field.setMinimumWidth(250) + layout.addRow("Experiment name:", self._experiment_name_field) + self._experiment_name_field.setValidator( NotInStorage(self.notifier.storage, "experiments") ) + self._ensemble_name_field = StringBox( TextModel(""), placeholder_text="ensemble" ) @@ -73,21 +78,33 @@ def __init__(self, ensemble_size: int, run_path: str, notifier: ErtNotifier): self._active_realizations_field.getValidationSupport().validationChanged.connect( # noqa self.simulationConfigurationChanged ) - self._name_field.getValidationSupport().validationChanged.connect( # noqa + self._experiment_name_field.getValidationSupport().validationChanged.connect( # noqa self.simulationConfigurationChanged ) self._ensemble_name_field.getValidationSupport().validationChanged.connect( # noqa self.simulationConfigurationChanged ) + self.notifier.ertChanged.connect(self._update_experiment_name_placeholder) + + @Slot(ExperimentConfigPanel) + def experimentTypeChanged(self, w: ExperimentConfigPanel) -> None: + if isinstance(w, EnsembleExperimentPanel): + self._update_experiment_name_placeholder() + + def _update_experiment_name_placeholder(self) -> None: + self._experiment_name_field.setPlaceholderText( + self.notifier.storage.get_unique_experiment_name(ENSEMBLE_EXPERIMENT_MODE) + ) + def isConfigurationValid(self) -> bool: self.blockSignals(True) - self._name_field.validateString() + self._experiment_name_field.validateString() self._ensemble_name_field.validateString() self.blockSignals(False) return ( self._active_realizations_field.isValid() - and self._name_field.isValid() + and self._experiment_name_field.isValid() and self._ensemble_name_field.isValid() ) @@ -96,5 +113,5 @@ def get_experiment_arguments(self) -> Arguments: mode=ENSEMBLE_EXPERIMENT_MODE, current_ensemble=self._ensemble_name_field.get_text, realizations=self._active_realizations_field.text(), - experiment_name=self._name_field.get_text, + experiment_name=self._experiment_name_field.get_text, ) diff --git a/src/ert/gui/simulation/ensemble_smoother_panel.py b/src/ert/gui/simulation/ensemble_smoother_panel.py index 840d326af47..119490a2f9c 100644 --- a/src/ert/gui/simulation/ensemble_smoother_panel.py +++ b/src/ert/gui/simulation/ensemble_smoother_panel.py @@ -3,17 +3,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from qtpy.QtWidgets import QFormLayout, QLabel, QLineEdit +from qtpy.QtCore import Slot +from qtpy.QtWidgets import QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import AnalysisModuleEdit +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.stringbox import StringBox from ert.mode_definitions import ENSEMBLE_SMOOTHER_MODE from ert.run_models import EnsembleSmoother from ert.validation import ProperNameFormatArgument, RangeStringArgument +from ert.validation.range_string_argument import NotInStorage from .experiment_config_panel import ExperimentConfigPanel @@ -43,10 +44,17 @@ def __init__( self.setObjectName("ensemble_smoother_panel") - self._name_field = QLineEdit() - self._name_field.setPlaceholderText(ENSEMBLE_SMOOTHER_MODE) - self._name_field.setMinimumWidth(250) - layout.addRow("Experiment name:", self._name_field) + self._experiment_name_field = StringBox( + TextModel(""), + placeholder_text=self.notifier.storage.get_unique_experiment_name( + ENSEMBLE_SMOOTHER_MODE + ), + ) + self._experiment_name_field.setMinimumWidth(250) + layout.addRow("Experiment name:", self._experiment_name_field) + self._experiment_name_field.setValidator( + NotInStorage(self.notifier.storage, "experiments") + ) runpath_label = CopyableLabel(text=run_path) layout.addRow("Runpath:", runpath_label) @@ -84,6 +92,18 @@ def __init__( self.simulationConfigurationChanged ) + self.notifier.ertChanged.connect(self._update_experiment_name_placeholder) + + @Slot(ExperimentConfigPanel) + def experimentTypeChanged(self, w: ExperimentConfigPanel) -> None: + if isinstance(w, EnsembleSmootherPanel): + self._update_experiment_name_placeholder() + + def _update_experiment_name_placeholder(self) -> None: + self._experiment_name_field.setPlaceholderText( + self.notifier.storage.get_unique_experiment_name(ENSEMBLE_SMOOTHER_MODE) + ) + def isConfigurationValid(self) -> bool: return ( self._ensemble_format_field.isValid() @@ -95,10 +115,6 @@ def get_experiment_arguments(self) -> Arguments: mode=ENSEMBLE_SMOOTHER_MODE, target_ensemble=self._ensemble_format_model.getValue(), realizations=self._active_realizations_field.text(), - experiment_name=( - self._name_field.text() - if self._name_field.text() - else self._name_field.placeholderText() - ), + experiment_name=self._experiment_name_field.get_text, ) return arguments diff --git a/src/ert/gui/simulation/experiment_config_panel.py b/src/ert/gui/simulation/experiment_config_panel.py index 8752e2b012c..c223d2bc60c 100644 --- a/src/ert/gui/simulation/experiment_config_panel.py +++ b/src/ert/gui/simulation/experiment_config_panel.py @@ -2,7 +2,7 @@ from typing import Any, Dict -from qtpy.QtCore import Signal +from qtpy.QtCore import Signal, Slot from qtpy.QtWidgets import QWidget @@ -17,10 +17,12 @@ def __init__(self, simulation_model): def get_experiment_type(self): return self.__simulation_model - @staticmethod - def isConfigurationValid() -> bool: + def isConfigurationValid(self) -> bool: return True - @staticmethod - def get_experiment_arguments() -> Dict[str, Any]: + def get_experiment_arguments(self) -> Dict[str, Any]: return {} + + @Slot(QWidget) + def experimentTypeChanged(self, w: QWidget): + pass diff --git a/src/ert/gui/simulation/experiment_panel.py b/src/ert/gui/simulation/experiment_panel.py index 850e513f5ca..ac1bd86cc2d 100644 --- a/src/ert/gui/simulation/experiment_panel.py +++ b/src/ert/gui/simulation/experiment_panel.py @@ -2,7 +2,7 @@ from queue import SimpleQueue from typing import Any, Dict, List -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QAction, @@ -43,6 +43,8 @@ class ExperimentPanel(QWidget): + experiment_type_changed = Signal(ExperimentConfigPanel) + def __init__( self, config: ErtConfig, @@ -68,9 +70,11 @@ def __init__( experiment_type_layout = QHBoxLayout() experiment_type_layout.addSpacing(10) - experiment_type_layout.addWidget(QLabel("Experiment type:"), 0, Qt.AlignVCenter) experiment_type_layout.addWidget( - self._experiment_type_combo, 0, Qt.AlignVCenter + QLabel("Experiment type:"), 0, Qt.AlignmentFlag.AlignVCenter + ) + experiment_type_layout.addWidget( + self._experiment_type_combo, 0, Qt.AlignmentFlag.AlignVCenter ) experiment_type_layout.addSpacing(20) @@ -148,6 +152,7 @@ def addExperimentConfigPanel(self, panel, mode_enabled: bool) -> None: sim_item.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxWarning)) panel.simulationConfigurationChanged.connect(self.validationStatusChanged) + self.experiment_type_changed.connect(panel.experimentTypeChanged) @staticmethod def getActions() -> List[QAction]: @@ -155,7 +160,7 @@ def getActions() -> List[QAction]: def get_current_experiment_type(self): return self._experiment_type_combo.itemData( - self._experiment_type_combo.currentIndex(), Qt.UserRole + self._experiment_type_combo.currentIndex(), Qt.ItemDataRole.UserRole ) def get_experiment_arguments(self) -> Dict[str, Any]: @@ -308,6 +313,7 @@ def toggleExperimentType(self) -> None: widget = self._experiment_widgets[self.get_current_experiment_type()] self._experiment_stack.setCurrentWidget(widget) self.validationStatusChanged() + self.experiment_type_changed.emit(widget) def validationStatusChanged(self) -> None: widget = self._experiment_widgets[self.get_current_experiment_type()] diff --git a/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py b/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py index 56372e4f955..f1bd4e18e9b 100644 --- a/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py +++ b/src/ert/gui/simulation/iterated_ensemble_smoother_panel.py @@ -3,17 +3,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from qtpy.QtWidgets import QFormLayout, QLabel, QLineEdit, QSpinBox +from qtpy.QtCore import Slot +from qtpy.QtWidgets import QFormLayout, QLabel, QSpinBox from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import AnalysisModuleEdit +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.stringbox import StringBox from ert.mode_definitions import ITERATIVE_ENSEMBLE_SMOOTHER_MODE from ert.run_models import IteratedEnsembleSmoother from ert.validation import ProperNameFormatArgument, RangeStringArgument +from ert.validation.range_string_argument import NotInStorage from .experiment_config_panel import ExperimentConfigPanel @@ -43,10 +44,17 @@ def __init__( self.analysis_config = analysis_config layout = QFormLayout() - self._name_field = QLineEdit() - self._name_field.setPlaceholderText("iterated_ensemble_smoother") - self._name_field.setMinimumWidth(250) - layout.addRow("Experiment name:", self._name_field) + self._experiment_name_field = StringBox( + TextModel(""), + placeholder_text=self.notifier.storage.get_unique_experiment_name( + ITERATIVE_ENSEMBLE_SMOOTHER_MODE + ), + ) + self._experiment_name_field.setMinimumWidth(250) + layout.addRow("Experiment name:", self._experiment_name_field) + self._experiment_name_field.setValidator( + NotInStorage(self.notifier.storage, "experiments") + ) runpath_label = CopyableLabel(text=run_path) layout.addRow("Runpath:", runpath_label) @@ -98,6 +106,20 @@ def __init__( ) self.setLayout(layout) + self.notifier.ertChanged.connect(self._update_experiment_name_placeholder) + + @Slot(ExperimentConfigPanel) + def experimentTypeChanged(self, w: ExperimentConfigPanel) -> None: + if isinstance(w, IteratedEnsembleSmootherPanel): + self._update_experiment_name_placeholder() + + def _update_experiment_name_placeholder(self) -> None: + self._experiment_name_field.setPlaceholderText( + self.notifier.storage.get_unique_experiment_name( + ITERATIVE_ENSEMBLE_SMOOTHER_MODE + ) + ) + def setNumberIterations(self, iteration_count): if iteration_count != self.analysis_config.num_iterations: self.analysis_config.set_num_iterations(iteration_count) @@ -115,9 +137,5 @@ def get_experiment_arguments(self) -> Arguments: target_ensemble=self._iterated_target_ensemble_format_model.getValue(), realizations=self._active_realizations_field.text(), num_iterations=self._num_iterations_spinner.value(), - experiment_name=( - self._name_field.text() - if self._name_field.text() - else self._name_field.placeholderText() - ), + experiment_name=self._experiment_name_field.get_text, ) diff --git a/src/ert/gui/simulation/multiple_data_assimilation_panel.py b/src/ert/gui/simulation/multiple_data_assimilation_panel.py index 24e32f2c40f..c01f275889e 100644 --- a/src/ert/gui/simulation/multiple_data_assimilation_panel.py +++ b/src/ert/gui/simulation/multiple_data_assimilation_panel.py @@ -4,15 +4,20 @@ from typing import TYPE_CHECKING, Any, List from qtpy.QtCore import Slot -from qtpy.QtWidgets import QCheckBox, QFormLayout, QLabel, QLineEdit +from qtpy.QtWidgets import QCheckBox, QFormLayout, QLabel from ert.gui.ertnotifier import ErtNotifier -from ert.gui.ertwidgets import ActiveLabel, AnalysisModuleEdit, EnsembleSelector +from ert.gui.ertwidgets import ( + ActiveLabel, + AnalysisModuleEdit, + EnsembleSelector, + 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.models.valuemodel import ValueModel -from ert.gui.ertwidgets.stringbox import StringBox from ert.mode_definitions import ES_MDA_MODE from ert.run_models import MultipleDataAssimilation from ert.validation import ( @@ -20,6 +25,7 @@ ProperNameFormatArgument, RangeStringArgument, ) +from ert.validation.range_string_argument import NotInStorage from .experiment_config_panel import ExperimentConfigPanel @@ -52,11 +58,17 @@ def __init__( layout = QFormLayout() self.setObjectName("ES_MDA_panel") - self._experiment_name_field = QLineEdit() - self._experiment_name_field.setPlaceholderText("es_mda") + self._experiment_name_field = StringBox( + TextModel(""), + placeholder_text=self.notifier.storage.get_unique_experiment_name( + ES_MDA_MODE + ), + ) self._experiment_name_field.setMinimumWidth(250) layout.addRow("Experiment name:", self._experiment_name_field) - + self._experiment_name_field.setValidator( + NotInStorage(self.notifier.storage, "experiments") + ) runpath_label = CopyableLabel(text=run_path) layout.addRow("Runpath:", runpath_label) @@ -117,6 +129,18 @@ def __init__( self.setLayout(layout) + self.notifier.ertChanged.connect(self._update_experiment_name_placeholder) + + @Slot(ExperimentConfigPanel) + def experimentTypeChanged(self, w: ExperimentConfigPanel) -> None: + if isinstance(w, MultipleDataAssimilationPanel): + self._update_experiment_name_placeholder() + + def _update_experiment_name_placeholder(self) -> None: + self._experiment_name_field.setPlaceholderText( + self.notifier.storage.get_unique_experiment_name(ES_MDA_MODE) + ) + @Slot() def update_experiment_name(self) -> None: if not self._experiment_name_field.isEnabled(): @@ -197,11 +221,7 @@ def get_experiment_arguments(self) -> Arguments: if self._restart_box.isChecked() else "" ), - experiment_name=( - self._experiment_name_field.text() - if self._experiment_name_field.text() - else self._experiment_name_field.placeholderText() - ), + experiment_name=self._experiment_name_field.get_text, ) def setWeights(self, weights: Any) -> None: diff --git a/src/ert/storage/local_storage.py b/src/ert/storage/local_storage.py index 61d5914910d..a3fd4cd3833 100644 --- a/src/ert/storage/local_storage.py +++ b/src/ert/storage/local_storage.py @@ -491,6 +491,37 @@ def _migrate(self) -> None: ) raise e + def get_unique_experiment_name(self, experiment_name: str) -> str: + """ + Get a unique experiment name + + If an experiment with the given name exists an _0 is appended + or _n+1 where n is the the largest postfix found for the given experiment name + """ + if not experiment_name: + return self.get_unique_experiment_name("default") + + if experiment_name not in [e.name for e in self.experiments]: + return experiment_name + + if ( + len( + same_prefix := [ + e.name + for e in self.experiments + if e.name.startswith(experiment_name + "_") + ] + ) + > 0 + ): + return ( + experiment_name + + "_" + + str(max(int(e[e.rfind("_") + 1 :]) for e in same_prefix) + 1) + ) + else: + return experiment_name + "_0" + def _storage_version(path: Path) -> Optional[int]: if not path.exists(): diff --git a/src/ert/validation/range_string_argument.py b/src/ert/validation/range_string_argument.py index 5e2d1f2ba26..c30f01a3d56 100644 --- a/src/ert/validation/range_string_argument.py +++ b/src/ert/validation/range_string_argument.py @@ -48,7 +48,7 @@ def validate(self, token: str) -> ValidationStatus: return validation_status -class NotInStorage: +class NotInStorage(ArgumentDefinition): def __init__(self, storage: Storage, prop: str) -> None: self.storage = storage self.prop = prop diff --git a/tests/unit_tests/gui/simulation/test_run_path_dialog.py b/tests/unit_tests/gui/simulation/test_run_path_dialog.py index d59b5b67637..b4eae840396 100644 --- a/tests/unit_tests/gui/simulation/test_run_path_dialog.py +++ b/tests/unit_tests/gui/simulation/test_run_path_dialog.py @@ -68,7 +68,7 @@ def test_run_path_deleted_error( assert isinstance(simulation_mode_combo, QComboBox) simulation_mode_combo.setCurrentText(EnsembleExperiment.name()) simulation_settings = gui.findChild(EnsembleExperimentPanel) - simulation_settings._name_field.setText("new_experiment_name") + simulation_settings._experiment_name_field.setText("new_experiment_name") # Click start simulation and agree to the message run_experiment = experiment_panel.findChild(QWidget, name="run_experiment") @@ -117,7 +117,7 @@ def test_run_path_is_deleted(snake_oil_case_storage: ErtConfig, qtbot: QtBot): assert isinstance(simulation_mode_combo, QComboBox) simulation_mode_combo.setCurrentText(EnsembleExperiment.name()) simulation_settings = gui.findChild(EnsembleExperimentPanel) - simulation_settings._name_field.setText("new_experiment_name") + simulation_settings._experiment_name_field.setText("new_experiment_name") # Click start simulation and agree to the message run_experiment = experiment_panel.findChild(QWidget, name="run_experiment") @@ -164,7 +164,7 @@ def test_run_path_is_not_deleted(snake_oil_case_storage: ErtConfig, qtbot: QtBot assert isinstance(simulation_mode_combo, QComboBox) simulation_mode_combo.setCurrentText(EnsembleExperiment.name()) simulation_settings = gui.findChild(EnsembleExperimentPanel) - simulation_settings._name_field.setText("new_experiment_name") + simulation_settings._experiment_name_field.setText("new_experiment_name") # Click start simulation and agree to the message run_experiment = experiment_panel.findChild(QWidget, name="run_experiment") diff --git a/tests/unit_tests/storage/test_local_storage.py b/tests/unit_tests/storage/test_local_storage.py index 5f04ab959af..7810f387beb 100644 --- a/tests/unit_tests/storage/test_local_storage.py +++ b/tests/unit_tests/storage/test_local_storage.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List -from unittest.mock import patch +from unittest.mock import MagicMock, PropertyMock, patch from uuid import UUID import hypothesis.strategies as st @@ -338,6 +338,38 @@ def test_remove_and_add_response_from_storage( assert resp.equals(gpr_diff_ds) +def test_get_unique_experiment_name(snake_oil_storage): + with patch( + "ert.storage.local_storage.LocalStorage.experiments", new_callable=PropertyMock + ) as experiments: + # Its not possible to do MagicMock(name="experiment_name") therefore the workaround below + names = [ + "experiment", + "experiment_1", + "experiment_8", + "_d_e_", + "___name__0___", + "__name__1", + "default", + ] + experiment_list = [MagicMock() for _ in range(len(names))] + for k, v in zip(experiment_list, names): + k.name = v + experiments.return_value = experiment_list + + assert snake_oil_storage.get_unique_experiment_name("_d_e_") == "_d_e__0" + assert ( + snake_oil_storage.get_unique_experiment_name("experiment") == "experiment_9" + ) + assert ( + snake_oil_storage.get_unique_experiment_name("___name__0___") + == "___name__0____0" + ) + assert snake_oil_storage.get_unique_experiment_name("name") == "name" + assert snake_oil_storage.get_unique_experiment_name("__name__") == "__name__" + assert snake_oil_storage.get_unique_experiment_name("") == "default_0" + + parameter_configs = st.lists( st.one_of( st.builds(