Skip to content

Commit 5f340d7

Browse files
committed
Add simple statistics interface
The statistics customization tab disappeared with the plot customization dialog, leaving no way to choose which statistics to show. Reintroduce the controls in the plot sidebar, letting the user select statistics and whether bands are filled.
1 parent 9dbe19d commit 5f340d7

5 files changed

Lines changed: 280 additions & 4 deletions

File tree

src/ert/gui/plotting/plot_window.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@
5858
from .utils.plot_color_palettes import TABLEAU_10_COLOR_CYCLE
5959
from .utils.plot_types import ObservationPlotLocations
6060
from .utils.qt_creator import create_group_box, create_group_layout, create_side_panel
61-
from .utils.statistics_style import DEFAULT_ENABLED_STATISTICS
6261
from .widgets.data_type_keys_widget import DataTypeKeysWidget
6362
from .widgets.everest_control_selection_widget import EverestControlSelectionWidget
6463
from .widgets.plot_controls import (
6564
BoxplotOptions,
6665
EverestControlsPlotOptions,
6766
GeneralPlotOptions,
67+
StatisticsOptions,
6868
)
6969
from .widgets.plot_ensemble_selection_widget import EnsembleSelectionWidget
7070
from .widgets.plot_widget import Plotter, PlotWidget
@@ -301,6 +301,7 @@ def __init__(
301301
self._general_options.axisLabelEditRequested.connect(self._edit_axis_label)
302302
self._general_options.titleEditRequested.connect(self._edit_title)
303303
self._boxplot_options = BoxplotOptions(self.update_plot)
304+
self._statistics_options = StatisticsOptions(self.update_plot)
304305

305306
right_container = QWidget()
306307
right_layout = create_group_layout(
@@ -310,13 +311,15 @@ def __init__(
310311
self._everest_controls_plot_options.get_widget(),
311312
self._everest_controls_group,
312313
self._boxplot_options.get_widget(),
314+
self._statistics_options.get_widget(),
313315
]
314316
)
315317
right_container.setLayout(right_layout)
316318

317319
self._everest_controls_group.setVisible(False)
318320
self._everest_controls_plot_options.get_widget().setVisible(False)
319321
self._boxplot_options.get_widget().setVisible(False)
322+
self._statistics_options.get_widget().setVisible(False)
320323
self._data_type_keys_widget.selectDefault()
321324

322325
splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -384,6 +387,7 @@ def update_plot(self, layer: int | None = None) -> None:
384387
self._boxplot_options.get_widget().setVisible(
385388
plot_widget.name in {MISFITS, CROSS_ENSEMBLE_STATISTICS}
386389
)
390+
self._statistics_options.get_widget().setVisible(plot_widget.name == STATISTICS)
387391
self._general_options.get_widget().setVisible(plot_widget.name != STD_DEV)
388392

389393
is_gradient_plot = plot_widget.name == EVEREST_GRADIENTS_PLOT
@@ -543,9 +547,7 @@ def fetch_data(
543547
plot_config.set_x_label(self._x_labels.get(key_def.key))
544548
plot_config.set_y_label(self._y_labels.get(key_def.key))
545549
if selected_tab == STATISTICS:
546-
plot_config.set_statistics_options(
547-
DEFAULT_ENABLED_STATISTICS, fill_bands=False
548-
)
550+
self._statistics_options.apply_to(plot_config)
549551
plot_config.set_legend_enabled(self._general_options.legend_checkbox_state)
550552
plot_config.set_grid_enabled(self._general_options.grid_checkbox_state)
551553
plot_config.set_line_color_cycle(self._general_options.get_color_cycle())

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

Lines changed: 71 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,44 @@ 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, name: str, *, expanded: bool = True
50+
) -> QGroupBox:
51+
"""Create a group box whose contents can be folded away by its header.
52+
53+
Collapsing only hides the contents; the widgets keep their values.
54+
"""
55+
content = QWidget()
56+
content.setObjectName(f"{name}_content")
57+
content.setLayout(layout)
58+
content.setVisible(expanded)
59+
60+
header = QToolButton()
61+
header.setObjectName(f"{name}_header")
62+
header.setText(title)
63+
header.setCheckable(True)
64+
header.setChecked(expanded)
65+
header.setAutoRaise(True)
66+
header.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
67+
header.setArrowType(Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow)
68+
header.setStyleSheet("QToolButton { border: none; font-style: italic; }")
69+
70+
def toggle_contents(checked: bool) -> None:
71+
header.setArrowType(
72+
Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
73+
)
74+
content.setVisible(checked)
75+
76+
header.toggled.connect(toggle_contents)
77+
78+
outer_layout = QVBoxLayout()
79+
outer_layout.setContentsMargins(0, 0, 0, 0)
80+
outer_layout.addWidget(header)
81+
outer_layout.addWidget(content)
82+
83+
return create_group_box("", outer_layout)
84+
85+
4586
def create_checkbox_with_tooltip(
4687
name: str,
4788
tooltip: str,
@@ -57,3 +98,33 @@ def create_checkbox_with_tooltip(
5798
checkbox.stateChanged.connect(connection_point)
5899
log_plot_option_usage_once(checkbox.clicked, logger, name)
59100
return checkbox
101+
102+
103+
def create_spinbox_with_tooltip(
104+
name: str,
105+
tooltip: str,
106+
connection_point: Callable[..., object],
107+
*,
108+
minimum: int,
109+
maximum: int,
110+
initial_value: int,
111+
logger: Logger,
112+
) -> QSpinBox:
113+
spinbox = QSpinBox()
114+
spinbox.setObjectName(f"{name.lower().replace(' ', '_')}_spinbox")
115+
spinbox.setToolTip(tooltip)
116+
spinbox.setRange(minimum, maximum)
117+
spinbox.setValue(initial_value)
118+
spinbox.valueChanged.connect(connection_point)
119+
log_plot_option_usage_once(spinbox.valueChanged, logger, name)
120+
return spinbox
121+
122+
123+
def create_labeled_row(label: str, widget: QWidget) -> QWidget:
124+
row = QWidget()
125+
layout = QHBoxLayout(row)
126+
layout.setContentsMargins(0, 0, 0, 0)
127+
layout.addWidget(QLabel(label))
128+
layout.addWidget(widget)
129+
layout.addStretch()
130+
return row

src/ert/gui/plotting/widgets/plot_controls/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from .everest_controls_plot_options import EverestControlsPlotOptions
44
from .general_options import GeneralPlotOptions
55
from .plot_color_palette_selector import PlotColorPaletteSelector
6+
from .statistics_options import StatisticsOptions
67

78
__all__ = [
89
"BoxplotOptions",
910
"CustomPaletteDialog",
1011
"EverestControlsPlotOptions",
1112
"GeneralPlotOptions",
1213
"PlotColorPaletteSelector",
14+
"StatisticsOptions",
1315
]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
from ert.gui.plotting.utils.statistics_style import (
17+
BAND_STATISTICS,
18+
DEFAULT_ENABLED_STATISTICS,
19+
STATISTIC_LABELS,
20+
)
21+
22+
if TYPE_CHECKING:
23+
from ert.gui.plotting.utils import PlotConfig
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class StatisticsOptions:
29+
"""Owns the statistics selection, which persists across keys."""
30+
31+
def __init__(self, connection_point: Callable[..., object]) -> None:
32+
self._toggles = {
33+
statistic: create_checkbox_with_tooltip(
34+
label,
35+
f"Show or hide the {label} "
36+
+ ("band" if statistic in BAND_STATISTICS else "line"),
37+
connection_point,
38+
initial_checked=statistic in DEFAULT_ENABLED_STATISTICS,
39+
logger=logger,
40+
)
41+
for statistic, label in STATISTIC_LABELS.items()
42+
}
43+
self._area_toggle = create_checkbox_with_tooltip(
44+
"Area",
45+
"Draw the standard deviation, min/max and percentile ranges as a "
46+
"filled area instead of a pair of lines",
47+
connection_point,
48+
initial_checked=False,
49+
logger=logger,
50+
)
51+
self._std_dev_factor = create_spinbox_with_tooltip(
52+
"Std dev multiplier",
53+
"Choose the number of standard deviations to plot",
54+
connection_point,
55+
minimum=1,
56+
maximum=3,
57+
initial_value=1,
58+
logger=logger,
59+
)
60+
61+
self._statistics_options = create_collapsible_group_box(
62+
"Statistics options",
63+
create_group_layout(
64+
[
65+
*self._toggles.values(),
66+
self._area_toggle,
67+
create_labeled_row("Std dev multiplier", self._std_dev_factor),
68+
]
69+
),
70+
"statistics_options",
71+
)
72+
73+
def apply_to(self, plot_config: PlotConfig) -> None:
74+
plot_config.set_standard_deviation_factor(self._std_dev_factor.value())
75+
plot_config.set_statistics_options(
76+
self._enabled_statistics(),
77+
fill_bands=self._area_toggle.isChecked(),
78+
)
79+
80+
def _enabled_statistics(self) -> set[str]:
81+
return {
82+
statistic
83+
for statistic, checkbox in self._toggles.items()
84+
if checkbox.isChecked()
85+
}
86+
87+
def get_widget(self) -> QGroupBox:
88+
return self._statistics_options
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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.utils.statistics_style import STATISTIC_LABELS
7+
from ert.gui.plotting.widgets.plot_controls.statistics_options import StatisticsOptions
8+
9+
10+
def _header_and_content(options: StatisticsOptions) -> tuple[QToolButton, QWidget]:
11+
group_box = options.get_widget()
12+
header = group_box.findChild(QToolButton, "statistics_options_header")
13+
content = group_box.findChild(QWidget, "statistics_options_content")
14+
assert header is not None
15+
assert content is not None
16+
return header, content
17+
18+
19+
def test_that_statistics_options_group_starts_expanded(qtbot):
20+
options = StatisticsOptions(Mock())
21+
qtbot.addWidget(options.get_widget())
22+
header, content = _header_and_content(options)
23+
24+
assert header.isChecked()
25+
assert content.isVisibleTo(options.get_widget())
26+
27+
28+
def test_that_collapsing_statistics_options_hides_its_contents(qtbot):
29+
options = StatisticsOptions(Mock())
30+
qtbot.addWidget(options.get_widget())
31+
header, content = _header_and_content(options)
32+
33+
header.setChecked(False)
34+
assert not content.isVisibleTo(options.get_widget())
35+
36+
header.setChecked(True)
37+
assert content.isVisibleTo(options.get_widget())
38+
39+
40+
def test_that_statistics_options_have_expected_default_toggle_states(qtbot):
41+
options = StatisticsOptions(Mock())
42+
qtbot.addWidget(options.get_widget())
43+
44+
checked = {
45+
statistic
46+
for statistic, checkbox in options._toggles.items()
47+
if checkbox.isChecked()
48+
}
49+
50+
assert checked == {"mean", "p10-p90"}
51+
assert not options._area_toggle.isChecked()
52+
assert options._std_dev_factor.value() == 1
53+
54+
55+
def test_that_unchecked_statistics_are_hidden_in_the_plot_config(qtbot):
56+
options = StatisticsOptions(Mock())
57+
qtbot.addWidget(options.get_widget())
58+
plot_config = PlotConfig()
59+
60+
options.apply_to(plot_config)
61+
62+
for statistic in STATISTIC_LABELS:
63+
style = plot_config.get_statistics_style(statistic)
64+
if statistic in {"mean", "p10-p90"}:
65+
assert style.is_visible()
66+
else:
67+
assert not style.is_visible()
68+
69+
70+
def test_that_enabling_area_switches_band_statistics_to_a_filled_style(qtbot):
71+
options = StatisticsOptions(Mock())
72+
qtbot.addWidget(options.get_widget())
73+
74+
line_config = PlotConfig()
75+
options.apply_to(line_config)
76+
assert line_config.get_statistics_style("p10-p90").line_style == "--"
77+
78+
options._area_toggle.setChecked(True)
79+
area_config = PlotConfig()
80+
options.apply_to(area_config)
81+
assert area_config.get_statistics_style("p10-p90").line_style == "#"
82+
83+
84+
def test_that_the_std_dev_multiplier_is_applied_to_the_plot_config(qtbot):
85+
options = StatisticsOptions(Mock())
86+
qtbot.addWidget(options.get_widget())
87+
options._std_dev_factor.setValue(3)
88+
plot_config = PlotConfig()
89+
90+
options.apply_to(plot_config)
91+
92+
assert plot_config.get_standard_deviation_factor() == 3
93+
94+
95+
def test_that_toggling_a_statistic_invokes_the_connection_point(qtbot):
96+
connection_point = Mock()
97+
options = StatisticsOptions(connection_point)
98+
qtbot.addWidget(options.get_widget())
99+
100+
options._toggles["p50"].setChecked(True)
101+
102+
connection_point.assert_called()
103+
104+
105+
def test_that_applying_options_does_not_invoke_the_connection_point(qtbot):
106+
connection_point = Mock()
107+
options = StatisticsOptions(connection_point)
108+
qtbot.addWidget(options.get_widget())
109+
connection_point.reset_mock()
110+
111+
options.apply_to(PlotConfig())
112+
113+
connection_point.assert_not_called()

0 commit comments

Comments
 (0)