diff --git a/src/ert/ensemble_evaluator/event.py b/src/ert/ensemble_evaluator/event.py index b86cf35795a..abebe688d18 100644 --- a/src/ert/ensemble_evaluator/event.py +++ b/src/ert/ensemble_evaluator/event.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional +from typing import Dict, Optional from .snapshot import PartialSnapshot, Snapshot @@ -10,6 +10,9 @@ class _UpdateEvent: current_phase: int total_phases: int progress: float + done_realizations: int + realization_count: int + status_count: Dict[str, int] iteration: int diff --git a/src/ert/gui/model/progress_proxy.py b/src/ert/gui/model/progress_proxy.py deleted file mode 100644 index 25564452a04..00000000000 --- a/src/ert/gui/model/progress_proxy.py +++ /dev/null @@ -1,145 +0,0 @@ -from collections import defaultdict -from typing import Any, Dict, List, Optional, Union, overload - -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, QSize, Qt, QVariant -from qtpy.QtGui import QColor, QFont -from typing_extensions import override - -from ert.gui.model.snapshot import IsEnsembleRole, ProgressRole, StatusRole - - -class ProgressProxyModel(QAbstractItemModel): - def __init__( - self, source_model: QAbstractItemModel, parent: Optional[QModelIndex] = None - ) -> None: - QAbstractItemModel.__init__(self, parent) - self._source_model: QAbstractItemModel = source_model - self._progress: Optional[Dict[str, Union[dict[Any, Any], int]]] = None - self._connect() - - def _connect(self) -> None: - self._source_model.dataChanged.connect(self._source_data_changed) - self._source_model.rowsInserted.connect(self._source_rows_inserted) - self._source_model.modelAboutToBeReset.connect(self.modelAboutToBeReset) - self._source_model.modelReset.connect(self._source_reset) - - # rowCount-1 of the top index in the underlying, will be the last/most - # recent iteration. If it's -1, then there are no iterations yet. - last_iter: int = self._source_model.rowCount(QModelIndex()) - 1 - if last_iter >= 0: - self._recalculate_progress(last_iter) - - @override - def columnCount(self, parent: Optional[QModelIndex] = None) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return 1 - - @override - def rowCount(self, parent: Optional[QModelIndex] = None) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return 1 - - @override - def index( - self, row: int, column: int, parent: Optional[QModelIndex] = None - ) -> QModelIndex: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return QModelIndex() - return self.createIndex(row, column, None) - - @overload - def parent(self, child: QModelIndex) -> QModelIndex: ... - @overload - def parent(self) -> Optional[QObject]: ... - @override - def parent(self, child: Optional[QModelIndex] = None) -> Optional[QObject]: - return QModelIndex() - - @override - def hasChildren(self, parent: Optional[QModelIndex] = None) -> bool: - if parent is None: - return QModelIndex().isValid() - return not parent.isValid() - - @override - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return QVariant() - - if role == Qt.ItemDataRole.TextAlignmentRole: - return Qt.AlignmentFlag.AlignCenter - - if role == ProgressRole: - return self._progress - - if role in ( - Qt.ItemDataRole.StatusTipRole, - Qt.ItemDataRole.WhatsThisRole, - Qt.ItemDataRole.ToolTipRole, - ): - return "" - - if role == Qt.ItemDataRole.SizeHintRole: - return QSize(30, 30) - - if role == Qt.ItemDataRole.FontRole: - return QFont() - - if role in ( - Qt.ItemDataRole.BackgroundRole, - Qt.ItemDataRole.ForegroundRole, - Qt.ItemDataRole.DecorationRole, - ): - return QColor() - - if role == Qt.ItemDataRole.DisplayRole: - return "" - - return QVariant() - - def _recalculate_progress(self, iter_: int) -> None: - status_counts: Dict[Any, int] = defaultdict(int) - nr_reals: int = 0 - current_iter_index = self._source_model.index(iter_, 0, QModelIndex()) - if current_iter_index.internalPointer() is None: - self._progress = None - return - for row in range(0, self._source_model.rowCount(current_iter_index)): - real_index = self._source_model.index(row, 0, current_iter_index) - status = real_index.data(StatusRole) - nr_reals += 1 - status_counts[status] += 1 - self._progress = {"status": status_counts, "nr_reals": nr_reals} - - def _source_data_changed( - self, - top_left: QModelIndex, - _bottom_right: QModelIndex, - _roles: List[int], - ) -> None: - if top_left.internalPointer() is None: - return - if not top_left.data(IsEnsembleRole): - return - self._recalculate_progress(top_left.row()) - index = self.index(0, 0, QModelIndex()) - self.dataChanged.emit(index, index, [ProgressRole]) - - def _source_rows_inserted( - self, _parent: QModelIndex, start: int, _end: int - ) -> None: - self._recalculate_progress(start) - index = self.index(0, 0, QModelIndex()) - self.dataChanged.emit(index, index, [ProgressRole]) - - def _source_reset(self) -> None: - self._recalculate_progress(0) - self.modelReset.emit() diff --git a/src/ert/gui/simulation/run_dialog.py b/src/ert/gui/simulation/run_dialog.py index d4b94ae90b9..17f4297a4e6 100644 --- a/src/ert/gui/simulation/run_dialog.py +++ b/src/ert/gui/simulation/run_dialog.py @@ -34,7 +34,6 @@ from ert.gui.ertnotifier import ErtNotifier from ert.gui.ertwidgets.message_box import ErtMessageBox from ert.gui.model.job_list import JobListProxyModel -from ert.gui.model.progress_proxy import ProgressProxyModel from ert.gui.model.snapshot import COLUMNS, FileRole, IterNum, RealIens, SnapshotModel from ert.gui.tools.file import FileDialog from ert.gui.tools.plot.plot_tool import PlotTool @@ -52,7 +51,7 @@ from ..find_ert_info import find_ert_info from ..model.node import NodeType from .queue_emitter import QueueEmitter -from .view import LegendView, ProgressView, RealizationWidget, UpdateWidget +from .view import ProgressWidget, RealizationWidget, UpdateWidget _TOTAL_PROGRESS_TEMPLATE = "Total progress {total_progress}% — {phase_name}" @@ -89,8 +88,6 @@ def __init__( self._ticker = QTimer(self) self._ticker.timeout.connect(self._on_ticker) - progress_proxy_model = ProgressProxyModel(self._snapshot_model, parent=self) - self._total_progress_label = QLabel( _TOTAL_PROGRESS_TEMPLATE.format( total_progress=0, phase_name=run_model.getPhaseName() @@ -103,13 +100,7 @@ def __init__( self._total_progress_bar.setTextVisible(False) self._iteration_progress_label = QLabel(self) - - self._progress_view = ProgressView(self) - self._progress_view.setModel(progress_proxy_model) - self._progress_view.set_active_progress(False) - - legend_view = LegendView(self) - legend_view.setModel(progress_proxy_model) + self._progress_widget = ProgressWidget() self._tab_widget = QTabWidget(self) self._tab_widget.currentChanged.connect(self._current_tab_changed) @@ -177,8 +168,7 @@ def __init__( layout.addWidget(self._total_progress_label) layout.addWidget(self._total_progress_bar) layout.addWidget(self._iteration_progress_label) - layout.addWidget(self._progress_view) - layout.addWidget(legend_view) + layout.addWidget(self._progress_widget) layout.addWidget(self._tab_widget) layout.addWidget(self._job_label) layout.addWidget(self._job_view) @@ -382,14 +372,18 @@ def _on_event(self, event: object): elif isinstance(event, FullSnapshotEvent): if event.snapshot is not None: self._snapshot_model._add_snapshot(event.snapshot, event.iteration) - self._progress_view.set_active_progress() self.update_total_progress(event.progress, event.phase_name) + self._progress_widget.update_progress( + event.status_count, event.realization_count + ) elif isinstance(event, SnapshotUpdateEvent): if event.partial_snapshot is not None: self._snapshot_model._add_partial_snapshot( event.partial_snapshot, event.iteration ) - self._progress_view.set_active_progress() + self._progress_widget.update_progress( + event.status_count, event.realization_count + ) self.update_total_progress(event.progress, event.phase_name) elif isinstance(event, RunModelUpdateBeginEvent): iteration = event.iteration diff --git a/src/ert/gui/simulation/view/__init__.py b/src/ert/gui/simulation/view/__init__.py index 7d5e1b682b4..f905ab18f24 100644 --- a/src/ert/gui/simulation/view/__init__.py +++ b/src/ert/gui/simulation/view/__init__.py @@ -1,6 +1,5 @@ -from .legend import LegendView -from .progress import ProgressView +from .progress_widget import ProgressWidget from .realization import RealizationWidget from .update import UpdateWidget -__all__ = ["LegendView", "ProgressView", "RealizationWidget", "UpdateWidget"] +__all__ = ["ProgressWidget", "RealizationWidget", "UpdateWidget"] diff --git a/src/ert/gui/simulation/view/legend.py b/src/ert/gui/simulation/view/legend.py deleted file mode 100644 index 1b5915ef88d..00000000000 --- a/src/ert/gui/simulation/view/legend.py +++ /dev/null @@ -1,58 +0,0 @@ -import math - -from PyQt5.QtWidgets import QListView -from qtpy.QtCore import QModelIndex, Qt -from qtpy.QtGui import QColor, QPainter, QPalette -from qtpy.QtWidgets import ( - QApplication, - QFrame, - QStyledItemDelegate, - QStyleOptionViewItem, -) - -from ert.ensemble_evaluator.state import REAL_STATE_TO_COLOR -from ert.gui.model.progress_proxy import ProgressRole - - -class LegendView(QListView): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setItemDelegate(LegendDelegate(self)) - self.setFixedHeight(30) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setFrameShape(QFrame.NoFrame) - - -class LegendDelegate(QStyledItemDelegate): - @staticmethod - def paint(painter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - data = index.data(ProgressRole) - nr_reals = data["nr_reals"] if data else 0 - status = data["status"] if data else {} - - painter.save() - painter.setRenderHint(QPainter.Antialiasing, True) - painter.setRenderHint(QPainter.SmoothPixmapTransform, True) - - background_color = QApplication.palette().color(QPalette.Window) - painter.fillRect( - option.rect.x(), option.rect.y(), option.rect.width(), 30, background_color - ) - - total_states = len(REAL_STATE_TO_COLOR.items()) - delta = math.ceil(option.rect.width() / total_states) - x_offset = 0 - y = option.rect.y() + 5 - h = option.rect.height() - w = delta - 25 - - for state, color_ref in REAL_STATE_TO_COLOR.items(): - x = x_offset - painter.setBrush(QColor(*color_ref)) - painter.drawRect(x, y, 20, 20) - state_progress = status.get(state, 0) - text = f"{state} ({state_progress}/{nr_reals})" - painter.drawText(x + 25, y, w, h, Qt.AlignLeft, text) - x_offset += delta - - painter.restore() diff --git a/src/ert/gui/simulation/view/progress.py b/src/ert/gui/simulation/view/progress.py deleted file mode 100644 index 9eda71e9c05..00000000000 --- a/src/ert/gui/simulation/view/progress.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import math - -from qtpy.QtCore import QModelIndex, QSize, Qt -from qtpy.QtGui import QColor, QPainter -from qtpy.QtWidgets import ( - QProgressBar, - QStyledItemDelegate, - QStyleOptionViewItem, - QTreeView, - QVBoxLayout, - QWidget, -) - -from ert.ensemble_evaluator.state import REAL_STATE_TO_COLOR -from ert.gui.model.snapshot import ProgressRole - - -class ProgressView(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self._progress_tree_view = QTreeView(self) - self._progress_tree_view.setHeaderHidden(True) - self._progress_tree_view.setItemsExpandable(False) - self._progress_tree_view.setItemDelegate(ProgressDelegate(self)) - self._progress_tree_view.setRootIsDecorated(False) - self._progress_tree_view.setFixedHeight(30) - self._progress_tree_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._progress_bar = QProgressBar(self) - self._progress_bar.setRange(0, 0) - self._progress_bar.setFixedHeight(30) - self._progress_bar.setVisible(False) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._progress_tree_view) - layout.addWidget(self._progress_bar) - - self.setLayout(layout) - self.setFixedHeight(30) - - def setModel(self, model) -> None: - self._progress_tree_view.setModel(model) - - def set_active_progress(self, enable: bool = True) -> None: - self._progress_bar.setVisible(not enable) - self._progress_tree_view.setVisible(enable) - - -class ProgressDelegate(QStyledItemDelegate): - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self.background_color = QColor(200, 210, 210) - - def paint(self, painter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - data = index.data(ProgressRole) - if data is None: - return - - nr_reals: int = data["nr_reals"] - status: dict[str, int] = data["status"] - delta = option.rect.width() / nr_reals if nr_reals else 1 - - painter.save() - painter.setRenderHint(QPainter.Antialiasing, True) - painter.setRenderHint(QPainter.SmoothPixmapTransform, True) - painter.fillRect(option.rect, self.background_color) - - i = 0 - y = option.rect.y() - h = option.rect.height() - - for state, color_ref in REAL_STATE_TO_COLOR.items(): - if state in status: - state_progress = status[state] - x = math.ceil(option.rect.x() + i * delta) - w = math.ceil(state_progress * delta) - painter.fillRect(x, y, w, h, QColor(*color_ref)) - i += state_progress - - painter.restore() - - @staticmethod - def sizeHint(option, index) -> QSize: - return index.data(role=Qt.SizeHintRole) diff --git a/src/ert/gui/simulation/view/progress_widget.py b/src/ert/gui/simulation/view/progress_widget.py new file mode 100644 index 00000000000..2575dbb8d88 --- /dev/null +++ b/src/ert/gui/simulation/view/progress_widget.py @@ -0,0 +1,92 @@ +from typing import Any + +from qtpy.QtGui import QColor +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QProgressBar, + QVBoxLayout, + QWidget, +) + +from ert.ensemble_evaluator.state import REAL_STATE_TO_COLOR + + +class ProgressWidget(QFrame): + def __init__(self): + QWidget.__init__(self) + self.setFixedHeight(60) + + self._vertical_layout = QVBoxLayout(self) + self._vertical_layout.setContentsMargins(0, 0, 0, 0) + self._vertical_layout.setSpacing(0) + self.setLayout(self._vertical_layout) + + self._waiting_progress_bar = QProgressBar(self) + self._waiting_progress_bar.setRange(0, 0) + self._waiting_progress_bar.setFixedHeight(30) + self._vertical_layout.addWidget(self._waiting_progress_bar) + + self._progress_frame = QFrame(self) + self._vertical_layout.addWidget(self._progress_frame) + + self._horizontal_layout = QHBoxLayout(self._progress_frame) + self._horizontal_layout.setContentsMargins(0, 0, 0, 0) + self._horizontal_layout.setSpacing(0) + self._progress_frame.setLayout(self._horizontal_layout) + + self._legend_frame = QFrame(self) + self._vertical_layout.addWidget(self._legend_frame) + self._legend_frame.setFixedHeight(30) + self._horizontal_legend_layout = QHBoxLayout(self._legend_frame) + self._horizontal_legend_layout.setContentsMargins(0, 0, 0, 0) + self._horizontal_legend_layout.setSpacing(0) + + self._status = {} + self._realization_count = 0 + self.label_map = {} + self.legend_map_text = {} + + for i, c in REAL_STATE_TO_COLOR.items(): + label = QLabel(self) + label.setVisible(False) + label.setObjectName(f"progress_{i}") + label.setStyleSheet(f"background-color : {QColor(*c).name()}") + self.label_map[i] = label + self._horizontal_layout.addWidget(label) + + label = QLabel(self) + label.setFixedSize(20, 20) + label.setStyleSheet( + f"background-color : {QColor(*c).name()}; border: 1px solid black;" + ) + self._horizontal_legend_layout.addWidget(label) + + label = QLabel(self) + label.setObjectName(f"progress_label_text_{i}") + label.setText(f" {i} ({0}/{0})") + self.legend_map_text[i] = label + self._horizontal_legend_layout.addWidget(label) + + def repaint_components(self): + if self._realization_count > 0: + tot_w = self.width() + + self._waiting_progress_bar.setVisible(False) + for s, l in self.label_map.items(): + l.setVisible(True) + i = self._status.get(s, 0) + w = int((i / self._realization_count) * tot_w) + l.setFixedWidth(w) + + for s, l in self.legend_map_text.items(): + l.setText(f" {s} ({self._status.get(s,0)}/{self._realization_count})") + + def update_progress(self, status: dict[str, int], realization_count: int) -> None: + self._status = status + self._realization_count = realization_count + self.repaint_components() + + def resizeEvent(self, a0: Any, event: Any = None): + self.repaint_components() diff --git a/src/ert/run_models/base_run_model.py b/src/ert/run_models/base_run_model.py index 5166499db57..b267ad18aad 100644 --- a/src/ert/run_models/base_run_model.py +++ b/src/ert/run_models/base_run_model.py @@ -6,6 +6,7 @@ import shutil import time import uuid +from collections import defaultdict from contextlib import contextmanager from pathlib import Path from queue import SimpleQueue @@ -421,35 +422,33 @@ def checkHaveSufficientRealizations( f"MIN_REALIZATIONS to allow (more) failures in your experiments." ) - def _progress(self) -> float: - """Fraction of completed iterations over total iterations""" + def _current_status(self) -> tuple[dict[str, int], int, float, int]: + current_iter = max(list(self._iter_snapshot.keys())) + done_realizations = 0 + all_realizations = self._iter_snapshot[current_iter].reals + current_progress = 0.0 + status: dict[str, int] = defaultdict(int) + realization_count = len(all_realizations) + + if all_realizations: + for real in all_realizations.values(): + status[str(real.status)] += 1 - if self.isFinished(): - return 1.0 - elif not self._iter_snapshot: - return 0.0 - else: - # Calculate completed realizations - current_iter = max(list(self._iter_snapshot.keys())) - done_reals = 0 - all_reals = self._iter_snapshot[current_iter].reals - if not all_reals: - # Empty ensemble or all realizations deactivated - return 1.0 - for real in all_reals.values(): if real.status in [ REALIZATION_STATE_FINISHED, REALIZATION_STATE_FAILED, ]: - done_reals += 1 - real_progress = float(done_reals) / len(all_reals) + done_realizations += 1 - return ( - (current_iter + real_progress) / self.phaseCount() + realization_progress = float(done_realizations) / len(all_realizations) + current_progress = ( + (current_iter + realization_progress) / self.phaseCount() if self.phaseCount() != 1 - else real_progress + else realization_progress ) + return status, done_realizations, current_progress, realization_count + def send_end_event(self) -> None: self.send_event( EndEvent( @@ -463,12 +462,18 @@ def send_snapshot_event(self, event: CloudEvent) -> None: iter_ = event.data["iter"] snapshot = Snapshot(event.data) self._iter_snapshot[iter_] = snapshot + status, done_realizations, current_progress, realization_count = ( + self._current_status() + ) self.send_event( FullSnapshotEvent( phase_name=self.getPhaseName(), current_phase=self.currentPhase(), total_phases=self.phaseCount(), - progress=self._progress(), + progress=current_progress, + done_realizations=done_realizations, + realization_count=realization_count, + status_count=status, iteration=iter_, snapshot=copy.deepcopy(snapshot), ) @@ -482,12 +487,18 @@ def send_snapshot_event(self, event: CloudEvent) -> None: ) partial = PartialSnapshot(self._iter_snapshot[iter_]).from_cloudevent(event) self._iter_snapshot[iter_].merge_event(partial) + status, done_realizations, current_progress, realization_count = ( + self._current_status() + ) self.send_event( SnapshotUpdateEvent( phase_name=self.getPhaseName(), current_phase=self.currentPhase(), total_phases=self.phaseCount(), - progress=self._progress(), + progress=current_progress, + done_realizations=done_realizations, + realization_count=realization_count, + status_count=status, iteration=iter_, partial_snapshot=partial, ) diff --git a/tests/unit_tests/gui/simulation/view/test_legend.py b/tests/unit_tests/gui/simulation/view/test_legend.py index 8a70976c9c6..6ed51d0e696 100644 --- a/tests/unit_tests/gui/simulation/view/test_legend.py +++ b/tests/unit_tests/gui/simulation/view/test_legend.py @@ -1,16 +1,75 @@ -from PyQt5.QtCore import QModelIndex +from random import randint -from ert.gui.model.progress_proxy import ProgressProxyModel -from ert.gui.model.snapshot import SnapshotModel -from ert.gui.simulation.view.legend import LegendView +from qtpy.QtWidgets import QLabel +from ert.ensemble_evaluator.state import REAL_STATE_TO_COLOR +from ert.gui.simulation.view import ProgressWidget +from tests.unit_tests.gui.conftest import get_child -def test_delegate_instantiated(qtbot, large_snapshot): - snapshot_model = SnapshotModel() - progress_proxy_model = ProgressProxyModel(snapshot_model) - snapshot_model._add_snapshot(SnapshotModel.prerender(large_snapshot), 0) - legend_view = LegendView() - qtbot.addWidget(legend_view) - legend_view.setModel(progress_proxy_model) - assert legend_view.itemDelegate(QModelIndex()) +def test_progress_step_changing(qtbot): + progress_widget = ProgressWidget() + qtbot.addWidget(progress_widget) + + realization_count = 1 + + for i in range(len(REAL_STATE_TO_COLOR.keys())): + status = {} + + # generate new list with one flag set + for u, state in enumerate(REAL_STATE_TO_COLOR.keys()): + status[state] = 1 if i == u else 0 + + progress_widget.update_progress(status, realization_count) + + for state in REAL_STATE_TO_COLOR: + label_marker = get_child( + progress_widget, + QLabel, + name=f"progress_label_text_{state}", + ) + + assert label_marker + count = status[state] + assert f" {state} ({count}/{realization_count})" in label_marker.text() + + +def test_progress_state_width_correct(qtbot): + progress_widget = ProgressWidget() + qtbot.addWidget(progress_widget) + status = {"Unknown": 20} + realization_count = 20 + progress_widget.update_progress(status, realization_count) + + progress_marker = get_child( + progress_widget, + QLabel, + name="progress_Unknown", + ) + + assert progress_marker + base_width = progress_marker.width() / realization_count + + spread = realization_count + gen_list = [] + + for _ in range(len(REAL_STATE_TO_COLOR)): + r = randint(0, spread) + gen_list.append(r) + spread -= r + + for i, state in enumerate(REAL_STATE_TO_COLOR.keys()): + status[state] = gen_list[i] + + progress_widget.update_progress(status, realization_count) + + for state in REAL_STATE_TO_COLOR: + progress_marker = get_child( + progress_widget, + QLabel, + name=f"progress_{state}", + ) + + assert progress_marker + count = status.get(state, 0) + assert progress_marker.width() == base_width * count