Skip to content

Redesign some elements of the experiment view#13272

Open
erlenlh wants to merge 1 commit intomainfrom
manage-experiments-gui
Open

Redesign some elements of the experiment view#13272
erlenlh wants to merge 1 commit intomainfrom
manage-experiments-gui

Conversation

@erlenlh
Copy link
Copy Markdown
Contributor

@erlenlh erlenlh commented Apr 9, 2026

Issue
Resolves #12660

Approach
Changes to the GUI:

  1. Renamed the tab
  2. Changed the look of the "add experiment" button
  3. Added the ability to sort by columns when you click on them
  4. Added a creation timestamp in the view of an experiment

AFTER:
bilde

BEFORE:
bilde

  • PR title captures the intent of the changes, and is fitting for release notes.
  • Added appropriate release note label
  • Commit history is consistent and clean, in line with the contribution guidelines.
  • Make sure unit tests pass locally after every commit (git rebase -i main --exec 'just rapid-tests')

When applicable

  • When there are user facing changes: Updated documentation
  • New behavior or changes to existing untested code: Ensured that unit tests are added (See Ground Rules).
  • Large PR: Prepare changes in small commits for more convenient review
  • Bug fix: Add regression test for the bug
  • Bug fix: Add backport label to latest release (format: 'backport release-branch-name')

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the “Manage Experiments” GUI to make the experiment list clearer and more interactive, including a more discoverable add button, sortable columns, and showing an experiment creation timestamp in the detail panel.

Changes:

  • Renames the main tab to “Experiments Overview” and updates the “Add new experiment” button to a text button.
  • Enables column sorting in the experiments tree view (via a proxy model and UserRole sort keys).
  • Adds a “Created at” field to the experiment detail view, derived from ensemble start times.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/ert/gui/tools/manage_experiments/storage_widget.py Switches add button to QPushButton and enables sortable/filterable tree view with a custom sorting proxy.
src/ert/gui/tools/manage_experiments/storage_model.py Renames the time column header and adds UserRole data for stable sorting (name + timestamp).
src/ert/gui/tools/manage_experiments/storage_info_widget.py Displays an experiment “Created at” timestamp in the experiment detail panel.
src/ert/gui/tools/manage_experiments/manage_experiments_panel.py Renames the first tab label to “Experiments Overview”.

Comment on lines +126 to +130
elif role == Qt.ItemDataRole.UserRole:
if col == _Column.NAME:
return self._name
if col == _Column.TIME:
return self._children[0]._start_time if self._children else None
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExperimentModel uses self._children[0]._start_time as the value for the "Created" column when sorting (UserRole). Since LocalStorage._load_ensembles() inserts ensembles sorted by started_at descending, index 0 corresponds to the newest ensemble, not the experiment’s creation time. This makes the "Created" column (and sorting by it) misleading and inconsistent with the experiment details view which uses the earliest ensemble time. Consider computing the earliest (or otherwise explicitly defined) timestamp across all child ensembles for both DisplayRole and UserRole so the semantics match the column header.

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +125
try:
ensemble_start_times = [ens.started_at for ens in experiment.ensembles]
if ensemble_start_times:
created_text = min(ensemble_start_times).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
created_text = "Unknown"
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setExperiment() wraps the created-at computation in a broad except Exception, which can silently hide real bugs (e.g., storage access problems) and make UI issues hard to diagnose. Since started_at appears to be a datetime, this can be handled without a blanket exception by filtering/handling empty iterables explicitly (e.g., compute min with a default, or guard on an empty list) and only catching the specific exceptions you expect.

Suggested change
try:
ensemble_start_times = [ens.started_at for ens in experiment.ensembles]
if ensemble_start_times:
created_text = min(ensemble_start_times).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
created_text = "Unknown"
ensemble_start_times = [
ens.started_at
for ens in experiment.ensembles
if ens.started_at is not None
]
if ensemble_start_times:
created_text = min(ensemble_start_times).strftime("%Y-%m-%d %H:%M:%S")

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +130
proxy_model.setSortRole(Qt.ItemDataRole.UserRole)
proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
proxy_model.setDynamicSortFilter(True)
proxy_model.sort(0, Qt.SortOrder.AscendingOrder)

self._tree_view.setModel(proxy_model)
self._tree_view.setSortingEnabled(True)
header = self._tree_view.header()
header.setSortIndicatorShown(True)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting via clickable headers is new behavior (setSortingEnabled(True) + setSortRole(UserRole) + custom lessThan). There are existing GUI tests for the Manage Experiments tool, but none appear to assert that clicking the header actually reorders items correctly (including the numeric realization ordering and created-time ordering). Please add a UI test that clicks the header sections and verifies the resulting order so this feature doesn’t regress.

Copilot generated this review using guidance from repository custom instructions.
@erlenlh erlenlh force-pushed the manage-experiments-gui branch from b36f57a to 4ce918f Compare April 9, 2026 11:56
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 9, 2026

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
3517 2 3515 66
View the top 3 failed test(s) by shortest run time
tests/ert/ui_tests/gui/test_full_manual_update_workflow.py::test_manual_analysis_workflow[ES Update]
Stack Traces | 2.92s run time
ensemble_experiment_has_run = <ert.gui.main_window.ErtMainWindow object at 0x7f5b3e5a4910>
qtbot = <pytestqt.qtbot.QtBot object at 0x7f5b3eb3c260>, mode = 'ES Update'

    @pytest.mark.parametrize("mode", ["ES Update", "EnIF Update (Experimental)"])
    def test_manual_analysis_workflow(ensemble_experiment_has_run, qtbot, mode):
        """This runs a full manual update workflow, first running ensemble experiment
        where some of the realizations fail, then doing an update before running an
        ensemble experiment again to calculate the forecast of the update.
        """
        gui = ensemble_experiment_has_run
    
        # Select correct experiment in the simulation panel
        experiment_panel = get_child(gui, ExperimentPanel)
        simulation_mode_combo = get_child(experiment_panel, QComboBox)
        simulation_mode_combo.setCurrentText(ManualUpdate.name())
    
        update_mode_dropdown = experiment_panel.findChild(
            QComboBox, "manual_update_method_dropdown"
        )
        update_mode_dropdown.setCurrentText(mode)
        with contextlib.suppress(FileNotFoundError):
            shutil.rmtree("poly_out")
    
        # Click start simulation and agree to the message
        run_experiment = get_child(experiment_panel, QWidget, name="run_experiment")
        qtbot.mouseClick(run_experiment, Qt.MouseButton.LeftButton)
        # The Run dialog opens, wait until done appears, then click done
        run_dialog = wait_for_child(gui, qtbot, RunDialog)
        qtbot.waitUntil(lambda: run_dialog.is_experiment_done() is True, timeout=10000)
        qtbot.waitUntil(lambda: run_dialog._tab_widget.currentWidget() is not None)
    
        button_manage_experiments = gui.findChild(QToolButton, "button_Manage_experiments")
        assert button_manage_experiments
        qtbot.mouseClick(button_manage_experiments, Qt.MouseButton.LeftButton)
        experiments_panel = gui.findChild(ManageExperimentsPanel)
        assert experiments_panel
    
        # In the "create new case" tab, it should now contain "iter-1"
        experiments_panel.setCurrentIndex(0)
        current_tab = experiments_panel.currentWidget()
        assert current_tab
        assert current_tab.objectName() == "create_new_ensemble_tab"
        storage_widget = get_child(current_tab, StorageWidget)
        tree_view = get_child(storage_widget, QTreeView)
        tree_view.expandAll()
    
        model = tree_view.model()
        assert model is not None
        assert model.rowCount() == 2
>       assert model.data(model.index(1, 0)) == "ensemble_experiment"
E       AssertionError: assert 'Manual update of iter-0' == 'ensemble_experiment'
E         
E         - ensemble_experiment
E         + Manual update of iter-0

.../ui_tests/gui/test_full_manual_update_workflow.py:68: AssertionError
tests/ert/ui_tests/gui/test_full_manual_update_workflow.py::test_manual_analysis_workflow[EnIF Update (Experimental)]
Stack Traces | 4.91s run time
ensemble_experiment_has_run = <ert.gui.main_window.ErtMainWindow object at 0x7f5b3eb734d0>
qtbot = <pytestqt.qtbot.QtBot object at 0x7f5b8733f5f0>
mode = 'EnIF Update (Experimental)'

    @pytest.mark.parametrize("mode", ["ES Update", "EnIF Update (Experimental)"])
    def test_manual_analysis_workflow(ensemble_experiment_has_run, qtbot, mode):
        """This runs a full manual update workflow, first running ensemble experiment
        where some of the realizations fail, then doing an update before running an
        ensemble experiment again to calculate the forecast of the update.
        """
        gui = ensemble_experiment_has_run
    
        # Select correct experiment in the simulation panel
        experiment_panel = get_child(gui, ExperimentPanel)
        simulation_mode_combo = get_child(experiment_panel, QComboBox)
        simulation_mode_combo.setCurrentText(ManualUpdate.name())
    
        update_mode_dropdown = experiment_panel.findChild(
            QComboBox, "manual_update_method_dropdown"
        )
        update_mode_dropdown.setCurrentText(mode)
        with contextlib.suppress(FileNotFoundError):
            shutil.rmtree("poly_out")
    
        # Click start simulation and agree to the message
        run_experiment = get_child(experiment_panel, QWidget, name="run_experiment")
        qtbot.mouseClick(run_experiment, Qt.MouseButton.LeftButton)
        # The Run dialog opens, wait until done appears, then click done
        run_dialog = wait_for_child(gui, qtbot, RunDialog)
        qtbot.waitUntil(lambda: run_dialog.is_experiment_done() is True, timeout=10000)
        qtbot.waitUntil(lambda: run_dialog._tab_widget.currentWidget() is not None)
    
        button_manage_experiments = gui.findChild(QToolButton, "button_Manage_experiments")
        assert button_manage_experiments
        qtbot.mouseClick(button_manage_experiments, Qt.MouseButton.LeftButton)
        experiments_panel = gui.findChild(ManageExperimentsPanel)
        assert experiments_panel
    
        # In the "create new case" tab, it should now contain "iter-1"
        experiments_panel.setCurrentIndex(0)
        current_tab = experiments_panel.currentWidget()
        assert current_tab
        assert current_tab.objectName() == "create_new_ensemble_tab"
        storage_widget = get_child(current_tab, StorageWidget)
        tree_view = get_child(storage_widget, QTreeView)
        tree_view.expandAll()
    
        model = tree_view.model()
        assert model is not None
        assert model.rowCount() == 2
>       assert model.data(model.index(1, 0)) == "ensemble_experiment"
E       AssertionError: assert 'Manual update of iter-0' == 'ensemble_experiment'
E         
E         - ensemble_experiment
E         + Manual update of iter-0

.../ui_tests/gui/test_full_manual_update_workflow.py:68: AssertionError
tests/ert/unit_tests/gui/experiments/test_run_dialog.py::test_that_exception_in_run_model_is_displayed_in_a_suggestor_window_after_simulation_fails
Stack Traces | 360s run time
qtbot = <pytestqt.qtbot.QtBot object at 0x7f36fa520aa0>, use_tmpdir = None

    @pytest.mark.slow
    @pytest.mark.usefixtures("use_tmpdir")
    def test_that_exception_in_run_model_is_displayed_in_a_suggestor_window_after_simulation_fails(  # noqa E501
        qtbot: QtBot, use_tmpdir
    ):
        config_file = "minimal_config.ert"
        Path(config_file).write_text(
            "NUM_REALIZATIONS 1\nQUEUE_SYSTEM LOCAL", encoding="utf-8"
        )
        args_mock = Mock()
        args_mock.config = config_file
    
        ert_config = ErtConfig.from_file(config_file)
        with patch.object(
            ert.run_models.SingleTestRun,
            "run_experiment",
            MagicMock(side_effect=ValueError("I failed :(")),
        ):
            gui = _setup_main_window(ert_config, args_mock, GUILogHandler(), "storage")
            qtbot.addWidget(gui)
            run_experiment = gui.findChild(QToolButton, name="run_experiment")
    
            handler_done = False
    
            def assert_failure_in_error_dialog(run_dialog):
                nonlocal handler_done
                wait_until(lambda: run_dialog.fail_msg_box is not None, timeout=10000)
                suggestor_termination_window = run_dialog.fail_msg_box
                assert suggestor_termination_window
                text = (
                    suggestor_termination_window.findChild(
                        QWidget, name="suggestor_messages"
                    )
                    .findChild(QLabel)
                    .text()
                )
                assert "I failed :(" in text
                button = suggestor_termination_window.findChild(
                    QPushButton, name="close_button"
                )
                assert button
                button.click()
                handler_done = True
    
            simulation_mode_combo = gui.findChild(QComboBox)
            simulation_mode_combo.setCurrentText("Single realization test-run")
            qtbot.mouseClick(run_experiment, Qt.MouseButton.LeftButton)
            run_dialog = wait_for_child(gui, qtbot, RunDialog)
    
            QTimer.singleShot(100, lambda: assert_failure_in_error_dialog(run_dialog))
            # Capturing exceptions in order to catch an assertion error
            # from assert_failure_in_error_dialog and stop waiting
            with qtbot.captureExceptions() as exceptions:
                qtbot.waitUntil(
                    lambda: run_dialog.is_experiment_done() is True or bool(exceptions),
                    timeout=100000,
                )
                qtbot.waitUntil(lambda: handler_done or bool(exceptions), timeout=100000)
            if exceptions:
>               raise AssertionError(
                    f"Exception(s) happened in Qt event loop: {exceptions}"
                )
E               AssertionError: Exception(s) happened in Qt event loop: [(<class 'Failed'>, Timeout (>360.0s) from pytest-timeout., <traceback object at 0x7f36f80b37c0>)]

.../gui/experiments/test_run_dialog.py:659: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@erlenlh erlenlh force-pushed the manage-experiments-gui branch from 4ce918f to f027f48 Compare April 10, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI/UX Cleanup in "Manage Experiments"

3 participants