Skip to content

Commit a512053

Browse files
committed
Add simple statistics interface
Both settings only had controls in the removed customization dialog, even though the plotters still read them from PlotConfig. Add them to the sidebar so the statistics and distribution plots regain the options they lost.
1 parent ae2f9f1 commit a512053

6 files changed

Lines changed: 292 additions & 0 deletions

File tree

src/ert/gui/plotting/plot_window.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
HISTOGRAM,
4646
MISFITS,
4747
SHARED_PLOT_MAP,
48+
STATISTICS,
4849
STD_DEV,
4950
)
5051
from ert.gui.utils import is_everest_application
@@ -61,8 +62,10 @@
6162
from .widgets.everest_control_selection_widget import EverestControlSelectionWidget
6263
from .widgets.plot_controls import (
6364
BoxplotOptions,
65+
DistributionOptions,
6466
EverestControlsPlotOptions,
6567
GeneralPlotOptions,
68+
StatisticsOptions,
6669
)
6770
from .widgets.plot_ensemble_selection_widget import EnsembleSelectionWidget
6871
from .widgets.plot_widget import Plotter, PlotWidget
@@ -299,6 +302,8 @@ def __init__(
299302
self._general_options.axisLabelEditRequested.connect(self._edit_axis_label)
300303
self._general_options.titleEditRequested.connect(self._edit_title)
301304
self._boxplot_options = BoxplotOptions(self.update_plot)
305+
self._statistics_options = StatisticsOptions(self.update_plot)
306+
self._distribution_options = DistributionOptions(self.update_plot)
302307

303308
right_container = QWidget()
304309
right_layout = create_group_layout(
@@ -308,13 +313,17 @@ def __init__(
308313
self._everest_controls_plot_options.get_widget(),
309314
self._everest_controls_group,
310315
self._boxplot_options.get_widget(),
316+
self._statistics_options.get_widget(),
317+
self._distribution_options.get_widget(),
311318
]
312319
)
313320
right_container.setLayout(right_layout)
314321

315322
self._everest_controls_group.setVisible(False)
316323
self._everest_controls_plot_options.get_widget().setVisible(False)
317324
self._boxplot_options.get_widget().setVisible(False)
325+
self._statistics_options.get_widget().setVisible(False)
326+
self._distribution_options.get_widget().setVisible(False)
318327
self._data_type_keys_widget.selectDefault()
319328

320329
splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -382,6 +391,10 @@ def update_plot(self, layer: int | None = None) -> None:
382391
self._boxplot_options.get_widget().setVisible(
383392
plot_widget.name in {MISFITS, CROSS_ENSEMBLE_STATISTICS}
384393
)
394+
self._statistics_options.get_widget().setVisible(plot_widget.name == STATISTICS)
395+
self._distribution_options.get_widget().setVisible(
396+
plot_widget.name == DISTRIBUTION
397+
)
385398
self._general_options.get_widget().setVisible(plot_widget.name != STD_DEV)
386399

387400
is_gradient_plot = plot_widget.name == EVEREST_GRADIENTS_PLOT
@@ -540,6 +553,8 @@ def fetch_data(
540553
plot_config.set_title(self._titles.get(key_def.key, key_def.key))
541554
plot_config.set_x_label(self._x_labels.get(key_def.key))
542555
plot_config.set_y_label(self._y_labels.get(key_def.key))
556+
self._statistics_options.apply_to(plot_config)
557+
self._distribution_options.apply_to(plot_config)
543558
plot_config.set_legend_enabled(self._general_options.legend_checkbox_state)
544559
plot_config.set_grid_enabled(self._general_options.grid_checkbox_state)
545560
plot_config.set_line_color_cycle(self._general_options.get_color_cycle())

src/ert/gui/plotting/utils/qt_creator.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from PyQt6.QtWidgets import (
66
QCheckBox,
77
QGroupBox,
8+
QHBoxLayout,
89
QLabel,
10+
QSpinBox,
11+
QToolButton,
912
QVBoxLayout,
1013
QWidget,
1114
)
@@ -42,6 +45,48 @@ def create_group_box(title: str, layout: QVBoxLayout) -> QGroupBox:
4245
return group_box
4346

4447

48+
def create_collapsible_group_box(
49+
title: str, layout: QVBoxLayout, *, expanded: bool = True
50+
) -> QGroupBox:
51+
"""Create a group box whose contents can be folded away by its header.
52+
53+
The header is a disclosure arrow rather than a check, so it does not read
54+
as a switch that turns the section off. Collapsing only hides the
55+
contents; the widgets keep their values.
56+
"""
57+
name = title.lower().replace(" ", "_")
58+
59+
content = QWidget()
60+
content.setObjectName(f"{name}_content")
61+
content.setLayout(layout)
62+
content.setVisible(expanded)
63+
64+
header = QToolButton()
65+
header.setObjectName(f"{name}_header")
66+
header.setText(title)
67+
header.setCheckable(True)
68+
header.setChecked(expanded)
69+
header.setAutoRaise(True)
70+
header.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
71+
header.setArrowType(Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow)
72+
header.setStyleSheet("QToolButton { border: none; font-style: italic; }")
73+
74+
def toggle_contents(checked: bool) -> None:
75+
header.setArrowType(
76+
Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
77+
)
78+
content.setVisible(checked)
79+
80+
header.toggled.connect(toggle_contents)
81+
82+
outer_layout = QVBoxLayout()
83+
outer_layout.setContentsMargins(0, 0, 0, 0)
84+
outer_layout.addWidget(header)
85+
outer_layout.addWidget(content)
86+
87+
return create_group_box("", outer_layout)
88+
89+
4590
def create_checkbox_with_tooltip(
4691
name: str,
4792
tooltip: str,
@@ -57,3 +102,33 @@ def create_checkbox_with_tooltip(
57102
checkbox.stateChanged.connect(connection_point)
58103
log_plot_option_usage_once(checkbox.clicked, logger, name)
59104
return checkbox
105+
106+
107+
def create_spinbox_with_tooltip(
108+
name: str,
109+
tooltip: str,
110+
connection_point: Callable[..., object],
111+
*,
112+
minimum: int,
113+
maximum: int,
114+
initial_value: int,
115+
logger: Logger,
116+
) -> QSpinBox:
117+
spinbox = QSpinBox()
118+
spinbox.setObjectName(f"{name.lower().replace(' ', '_')}_spinbox")
119+
spinbox.setToolTip(tooltip)
120+
spinbox.setRange(minimum, maximum)
121+
spinbox.setValue(initial_value)
122+
spinbox.valueChanged.connect(connection_point)
123+
log_plot_option_usage_once(spinbox.valueChanged, logger, name)
124+
return spinbox
125+
126+
127+
def create_labeled_row(label: str, widget: QWidget) -> QWidget:
128+
row = QWidget()
129+
layout = QHBoxLayout(row)
130+
layout.setContentsMargins(0, 0, 0, 0)
131+
layout.addWidget(QLabel(label))
132+
layout.addWidget(widget)
133+
layout.addStretch()
134+
return row
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
from .boxplot_options import BoxplotOptions
22
from .custom_palette_dialog import CustomPaletteDialog
3+
from .distribution_options import DistributionOptions
34
from .everest_controls_plot_options import EverestControlsPlotOptions
45
from .general_options import GeneralPlotOptions
56
from .plot_color_palette_selector import PlotColorPaletteSelector
7+
from .statistics_options import StatisticsOptions
68

79
__all__ = [
810
"BoxplotOptions",
911
"CustomPaletteDialog",
12+
"DistributionOptions",
1013
"EverestControlsPlotOptions",
1114
"GeneralPlotOptions",
1215
"PlotColorPaletteSelector",
16+
"StatisticsOptions",
1317
]
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Callable
5+
from typing import TYPE_CHECKING
6+
7+
from PyQt6.QtWidgets import QGroupBox
8+
9+
from ert.gui.plotting.utils.qt_creator import (
10+
create_checkbox_with_tooltip,
11+
create_group_box,
12+
create_group_layout,
13+
)
14+
15+
if TYPE_CHECKING:
16+
from ert.gui.plotting.utils import PlotConfig
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class DistributionOptions:
22+
def __init__(self, connection_point: Callable[..., object]) -> None:
23+
self._connection_lines_toggle = create_checkbox_with_tooltip(
24+
"Connection lines",
25+
"Draw lines between the realizations of neighbouring ensembles",
26+
connection_point,
27+
initial_checked=False,
28+
logger=logger,
29+
)
30+
31+
self._distribution_options = create_group_box(
32+
"Distribution options",
33+
create_group_layout([self._connection_lines_toggle]),
34+
)
35+
36+
def apply_to(self, plot_config: PlotConfig) -> None:
37+
plot_config.set_distribution_line_enabled(
38+
self._connection_lines_toggle.isChecked()
39+
)
40+
41+
def get_widget(self) -> QGroupBox:
42+
return self._distribution_options
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Callable
5+
from typing import TYPE_CHECKING
6+
7+
from PyQt6.QtWidgets import QGroupBox
8+
9+
from ert.gui.plotting.utils.qt_creator import (
10+
create_checkbox_with_tooltip,
11+
create_collapsible_group_box,
12+
create_group_layout,
13+
create_labeled_row,
14+
create_spinbox_with_tooltip,
15+
)
16+
17+
if TYPE_CHECKING:
18+
from ert.gui.plotting.utils import PlotConfig
19+
20+
logger = logging.getLogger(__name__)
21+
22+
# (statistic, label, tooltip, checked by default)
23+
_STATISTICS = [
24+
("mean", "Mean", "Show or hide the mean", True),
25+
("p50", "P50", "Show or hide the P50 line", False),
26+
("std", "Std dev", "Show or hide the standard deviation band", False),
27+
("min-max", "Min/Max", "Show or hide the min/max band", False),
28+
("p10-p90", "P10-P90", "Show or hide the P10-P90 band", True),
29+
("p33-p67", "P33-P67", "Show or hide the P33-P67 band", False),
30+
]
31+
32+
_BAND_STATISTICS = frozenset({"std", "min-max", "p10-p90", "p33-p67"})
33+
34+
_AREA_LINE_STYLE = "#"
35+
_BAND_LINE_STYLE = "--"
36+
37+
_LINE_STYLE_WHEN_ENABLED = {"mean": "-", "p50": "--"}
38+
39+
40+
class StatisticsOptions:
41+
def __init__(self, connection_point: Callable[..., object]) -> None:
42+
self._toggles = {
43+
statistic: create_checkbox_with_tooltip(
44+
label, tooltip, connection_point, initial_checked=checked, logger=logger
45+
)
46+
for statistic, label, tooltip, checked in _STATISTICS
47+
}
48+
self._area_toggle = create_checkbox_with_tooltip(
49+
"Area",
50+
"Draw the standard deviation, min/max and percentile ranges as a "
51+
"filled area instead of a pair of lines",
52+
connection_point,
53+
initial_checked=False,
54+
logger=logger,
55+
)
56+
self._std_dev_factor = create_spinbox_with_tooltip(
57+
"Std dev multiplier",
58+
"Choose which standard deviation to plot",
59+
connection_point,
60+
minimum=1,
61+
maximum=3,
62+
initial_value=1,
63+
logger=logger,
64+
)
65+
66+
self._statistics_options = create_collapsible_group_box(
67+
"Statistics options",
68+
create_group_layout(
69+
[
70+
*self._toggles.values(),
71+
self._area_toggle,
72+
create_labeled_row("Std dev multiplier", self._std_dev_factor),
73+
]
74+
),
75+
)
76+
77+
def apply_to(self, plot_config: PlotConfig) -> None:
78+
"""Enable or disable each statistic according to its checkbox."""
79+
plot_config.set_standard_deviation_factor(self._std_dev_factor.value())
80+
fill_area = self._area_toggle.isChecked()
81+
for statistic, checkbox in self._toggles.items():
82+
style = plot_config.get_statistics_style(statistic)
83+
if not checkbox.isChecked():
84+
style.line_style = ""
85+
style.marker = ""
86+
elif statistic in _BAND_STATISTICS:
87+
style.line_style = _AREA_LINE_STYLE if fill_area else _BAND_LINE_STYLE
88+
style.marker = ""
89+
elif not style.is_visible():
90+
style.line_style = _LINE_STYLE_WHEN_ENABLED[statistic]
91+
style.marker = ""
92+
plot_config.set_statistics_style(statistic, style)
93+
94+
def get_widget(self) -> QGroupBox:
95+
return self._statistics_options
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from unittest.mock import Mock
2+
3+
from PyQt6.QtWidgets import QToolButton, QWidget
4+
5+
from ert.gui.plotting.utils import PlotConfig
6+
from ert.gui.plotting.widgets.plot_controls.statistics_options import StatisticsOptions
7+
8+
_STATISTIC_NAMES = ("mean", "p50", "std", "min-max", "p10-p90", "p33-p67")
9+
10+
11+
def _header_and_content(options: StatisticsOptions) -> tuple[QToolButton, QWidget]:
12+
group_box = options.get_widget()
13+
header = group_box.findChild(QToolButton, "statistics_options_header")
14+
content = group_box.findChild(QWidget, "statistics_options_content")
15+
assert header is not None
16+
assert content is not None
17+
return header, content
18+
19+
20+
def test_that_statistics_options_group_starts_expanded(qtbot):
21+
options = StatisticsOptions(Mock())
22+
qtbot.addWidget(options.get_widget())
23+
header, content = _header_and_content(options)
24+
25+
assert header.isChecked()
26+
assert content.isVisibleTo(options.get_widget())
27+
28+
29+
def test_that_collapsing_statistics_options_hides_its_contents(qtbot):
30+
options = StatisticsOptions(Mock())
31+
qtbot.addWidget(options.get_widget())
32+
header, content = _header_and_content(options)
33+
34+
header.setChecked(False)
35+
assert not content.isVisibleTo(options.get_widget())
36+
37+
header.setChecked(True)
38+
assert content.isVisibleTo(options.get_widget())
39+
40+
41+
def test_that_collapsing_statistics_options_leaves_the_plot_config_unchanged(qtbot):
42+
options = StatisticsOptions(Mock())
43+
qtbot.addWidget(options.get_widget())
44+
header, _ = _header_and_content(options)
45+
46+
expanded_config = PlotConfig()
47+
options.apply_to(expanded_config)
48+
49+
header.setChecked(False)
50+
collapsed_config = PlotConfig()
51+
options.apply_to(collapsed_config)
52+
53+
assert (
54+
collapsed_config.get_standard_deviation_factor()
55+
== expanded_config.get_standard_deviation_factor()
56+
)
57+
for statistic in _STATISTIC_NAMES:
58+
assert (
59+
collapsed_config.get_statistics_style(statistic).line_style
60+
== expanded_config.get_statistics_style(statistic).line_style
61+
)

0 commit comments

Comments
 (0)