From e494474d194fc4c19deac5439cf5b98e1d3879a3 Mon Sep 17 00:00:00 2001 From: Marvin Kaster Date: Fri, 28 Aug 2026 14:50:31 +0200 Subject: [PATCH 1/2] Add measurement points plot that supports 3 parameters and log-log scale --- ...ltiParameterMeasurementPointsPlotWidget.py | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py diff --git a/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py b/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py new file mode 100644 index 0000000..6984c5f --- /dev/null +++ b/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py @@ -0,0 +1,191 @@ +# This file is part of the Extra-P software (http://www.scalasca.org/software/extra-p) +# +# Copyright (c) 2020-2025, Technical University of Darmstadt, Germany +# +# This software may be modified and distributed under the terms of a BSD-style license. +# See the LICENSE file in the base directory for details. + +import numpy as np +from PySide6.QtGui import QColor +from matplotlib import patches as mpatches + +from extrap.gui.plots.BaseGraphWidget import GraphDisplayWindow +from extrap.util.formatting_helper import replace_method_parameters + + +##################################################################### + + +class MultiParameterMeasurementPointsPlot(GraphDisplayWindow): + """ + Shows the same information as the "Measurement points" plot (measurement + points and model surface for two parameters shown on the X- and Y-axis), + but instead of evaluating the model for a single, fixed value of the + remaining parameter, it draws one surface (and its measurement points) + for every discrete value of that parameter that occurs in the + measurements. This is intended for experiments with three parameters. + """ + + def __init__(self, graphWidget, main_widget, width=5, height=4, dpi=100): + super().__init__(graphWidget, main_widget, width, height, dpi) + + def draw_figure(self): + """ + This function draws the graph + """ + + # Get data + model_list, selected_callpaths = self.main_widget.get_selected_models() + if model_list is None or len(model_list) < 1: + return + + # Get max x and max y value as a initial default value or a value provided by user + maxX, maxY = self.get_max() + + # Get the callpath color map + widget = self.main_widget + dict_callpath_color = widget.model_color_map + + # Get base data for drawing points + parameter_x = self.main_widget.data_display.getAxisParameter(0) + parameter_y = self.main_widget.data_display.getAxisParameter(1) + parameters = self.main_widget.data_display.parameters + + # The third parameter is the first parameter that is not shown on an axis. + # Note: parameter_x.id/parameter_y.id are *positions* within `parameters` + # (as assigned by the axis combo boxes), not the parameters' own global ids, + # so the series parameter and its coordinate index must be found by position too. + series_parameter = None + series_parameter_index = None + for i, p in enumerate(parameters): + if i != parameter_x.id and i != parameter_y.id: + series_parameter = p + series_parameter_index = i + break + + # Set the x_label and y_label based on parameter selected. + x_label = parameter_x.name + if x_label.startswith("_"): + x_label = x_label[1:] + y_label = parameter_y.name + if y_label.startswith("_"): + y_label = y_label[1:] + + ax_all = self.fig.add_subplot(1, 1, 1, projection='3d') + + if series_parameter is None: + ax_all.text2D(0.5, 0.5, "This plot requires an experiment with at least 3 parameters", + transform=ax_all.transAxes, ha='center') + return + + # Determine the discrete values of the third parameter that occur in the measurements + series_values = sorted({ + m.coordinate[series_parameter_index] + for model in model_list for m in model.measurements + }) + + max_z = 0 + for model, callpath in zip(model_list, selected_callpaths): + base_color = QColor(dict_callpath_color[callpath]) + points = model.measurements + for value in series_values: + value_points = [m for m in points if m.coordinate[series_parameter_index] == value] + if not value_points: + continue + color = self._shade_color(base_color, value, series_values) + + if parameter_x.id >= 0: + xs = np.array([m.coordinate[parameter_x.id] for m in value_points]) + else: + xs = np.zeros(len(value_points)) + if parameter_y.id >= 0: + ys = np.array([m.coordinate[parameter_y.id] for m in value_points]) + else: + ys = np.full(len(value_points), max(min(np.min(xs), 0), 0)) + in_range = (xs <= maxX) & (ys <= maxY) + xs, ys = xs[in_range], ys[in_range] + + mean = np.array([m.mean for m in value_points])[in_range] + median = np.array([m.median for m in value_points])[in_range] + minimum = np.array([m.minimum for m in value_points])[in_range] + maximum = np.array([m.maximum for m in value_points])[in_range] + if len(maximum) > 0: + max_z = max(max_z, max(maximum)) + + # Draw points + ax_all.scatter(xs, ys, mean, color=color, marker='x') + ax_all.scatter(xs, ys, median, color=color, marker='+') + ax_all.scatter(xs, ys, minimum, color=color, marker='_') + ax_all.scatter(xs, ys, maximum, color=color, marker='_') + # Draw connecting line + line_x, line_y, line_z = [], [], [] + for x, y, min_v, max_v in zip(xs, ys, minimum, maximum): + line_x.append(x), line_x.append(x) + line_y.append(y), line_y.append(y) + line_z.append(min_v), line_z.append(max_v) + line_x.append(np.nan), line_y.append(np.nan), line_z.append(np.nan) + + ax_all.plot(line_x, line_y, line_z, color=color) + + # plot surfaces: one per model and per discrete value of the third parameter + pixel_gap_x, pixel_gap_y = self._calculate_grid_parameters(maxX, maxY) + x = np.arange(1.0, maxX, pixel_gap_x) + y = np.arange(1.0, maxY, pixel_gap_y) + X, Y = np.meshgrid(x, y) + + with np.errstate(invalid='ignore', divide='ignore'): + for model, callpath in zip(model_list, selected_callpaths): + function = model.hypothesis.function + base_color = QColor(dict_callpath_color[callpath]) + for value in series_values: + zs = self.calculate_z_optimized(X, Y, function, {series_parameter_index: value}) + finite = zs[np.logical_not(np.isinf(zs))] + if finite.size: + max_z = max(max_z, np.max(finite)) + zs[np.isinf(zs)] = max_z + Z = zs.reshape(X.shape) + color = self._shade_color(base_color, value, series_values) + ax_all.plot_surface(X, Y, Z, color=color, + rstride=1, cstride=1, antialiased=False, alpha=0.1) + + ax_all.mouse_init() + ax_all.xaxis.major.formatter._useMathText = True + ax_all.yaxis.major.formatter._useMathText = True + ax_all.zaxis.major.formatter._useMathText = True + ax_all.set_xlabel('\n' + x_label) + ax_all.set_ylabel('\n' + y_label, linespacing=3.1) + ax_all.set_zlabel( + '\n' + self.main_widget.get_selected_metric().name, linespacing=3.1) + ax_all.set_title("Measurement points") + + self._draw_legend(ax_all, dict_callpath_color, series_parameter, series_values) + + @staticmethod + def _shade_color(base_color: QColor, value, series_values): + """ Shades the callpath's base color from darker (lowest value of the + third parameter) to lighter (highest value), so that surfaces for + the same callpath but different parameter values stay visually + distinguishable while remaining recognizable as the same callpath. + """ + if len(series_values) <= 1: + return base_color.name() + position = series_values.index(value) / (len(series_values) - 1) + factor = 60 + position * 120 # 60 (darker) .. 180 (lighter) + return base_color.lighter(int(factor)).name() + + def _draw_legend(self, ax_all, dict_callpath_color, series_parameter, series_values): + patches = [] + for callpath, color in dict_callpath_color.items(): + base_color = QColor(color) + for value in series_values: + shade = self._shade_color(base_color, value, series_values) + label = str(callpath.name) + if label.startswith("_"): + label = label[1:] + label = replace_method_parameters(label) + label = f"{label} ({series_parameter.name}={value:g})" + patches.append(mpatches.Patch(color=shade, label=label)) + leg = ax_all.legend(handles=patches, fontsize=self.main_widget.plot_formatting_options.legend_font_size, + loc="upper right", bbox_to_anchor=(1, 1)) + if leg: + leg.set_draggable(True) From 68eb72fce5586102a2f913ecb9ec88d5642881f6 Mon Sep 17 00:00:00 2001 From: Marvin Kaster Date: Fri, 28 Aug 2026 14:52:15 +0200 Subject: [PATCH 2/2] Add measurement points plot that supports 3 parameters and log-log scale --- extrap/gui/AdvancedPlotWidget.py | 3 + extrap/gui/DataDisplay.py | 9 +- extrap/gui/MainWidget.py | 2 +- extrap/gui/PlotTypeSelector.py | 3 +- .../gui/components/plot_formatting_options.py | 23 +- extrap/gui/plots/BaseGraphWidget.py | 59 ++++- ...ltiParameterMeasurementPointsPlotWidget.py | 223 ++++++++++++++---- 7 files changed, 263 insertions(+), 59 deletions(-) diff --git a/extrap/gui/AdvancedPlotWidget.py b/extrap/gui/AdvancedPlotWidget.py index c38e1fa..885c002 100644 --- a/extrap/gui/AdvancedPlotWidget.py +++ b/extrap/gui/AdvancedPlotWidget.py @@ -87,6 +87,9 @@ def drawGraph(self): self.toolbar = MyCustomToolbar(self.graphDisplayWindow, self) self.grid.addWidget(self.graphDisplayWindow) self.grid.addWidget(self.toolbar) + controls_widget = self.graphDisplayWindow.get_controls_widget() + if controls_widget is not None: + self.grid.addWidget(controls_widget) else: self.graphDisplayWindow.redraw() diff --git a/extrap/gui/DataDisplay.py b/extrap/gui/DataDisplay.py index 3e7e652..1554c9d 100644 --- a/extrap/gui/DataDisplay.py +++ b/extrap/gui/DataDisplay.py @@ -27,6 +27,7 @@ from extrap.gui.plots.IsolinesDisplayWidget import IsolinesDisplay from extrap.gui.plots.MaxZAsSingleSurfacePlotWidget import MaxZAsSingleSurfacePlot from extrap.gui.plots.MeasurementPointsPlotWidget import MeasurementPointsPlot +from extrap.gui.plots.MultiParameterMeasurementPointsPlotWidget import MultiParameterMeasurementPointsPlot MIN_PARAM_VALUE = 0.01 MAX_PARAM_VALUE = float("inf") @@ -312,6 +313,7 @@ def reloadTabs(self, selectedCheckBoxesIndex): # 6: IsolinesDisplayWidget # 7: InterpolatedContourDisplayWidget # 8: Measurement Points + # 9: MultiParameterMeasurementPointsPlotWidget if 0 in selectedCheckBoxesIndex: labelText = "Line graph" tabStatus = self.is_tab_already_opened(labelText) @@ -327,7 +329,8 @@ def reloadTabs(self, selectedCheckBoxesIndex): 5: ("Heat map", HeatMapGraph), 6: ("Contour plot", IsolinesDisplay), 7: ("Interpolated contour", InterpolatedContourDisplay), - 8: ("Measurement points", MeasurementPointsPlot) + 8: ("Measurement points", MeasurementPointsPlot), + 9: ("Measurement points (3 parameters)", MultiParameterMeasurementPointsPlot) } for i in selectedCheckBoxesIndex: @@ -373,10 +376,10 @@ def parameterSelected(self, index, newName, oldName): old_value = self.axis_selections[index].getValue() for i in self.axis_selections: if i.index != index: - if i.getParameter().name == newName: + if i.getParameter().name == newName.name: self.setMaxValue(index, i.getValue()) self.setMaxValue(i.index, old_value) - i.switchParameter(oldName) + i.switchParameter(oldName.name) i.maxChanged() self.axis_selections[index].maxChanged() self.updateWidget() diff --git a/extrap/gui/MainWidget.py b/extrap/gui/MainWidget.py index 46465c7..059c769 100644 --- a/extrap/gui/MainWidget.py +++ b/extrap/gui/MainWidget.py @@ -183,7 +183,7 @@ def _init_ui(self): 'Dominating models in a 3D S&catter plot', 'Max &z as a single surface plot', 'Dominating models and max z as &heat map', 'Selected models in c&ontour plot', 'Selected models in &interpolated contour plots', - '&Measurement points'] + '&Measurement points', 'Measurement points (&3 parameters)'] graph_actions = [QAction(g, self) for g in graphs] for i, g in enumerate(graph_actions): slot = (lambda k: lambda: self.data_display.reloadTabs((k,)))(i) diff --git a/extrap/gui/PlotTypeSelector.py b/extrap/gui/PlotTypeSelector.py index dff3041..21f2160 100644 --- a/extrap/gui/PlotTypeSelector.py +++ b/extrap/gui/PlotTypeSelector.py @@ -22,7 +22,8 @@ def init_UI(self): plotTypes = ['Line graph', 'Selected models in same surface plot', 'Selected models in different surface plots', 'Dominating models in a 3D Scatter plot', 'Max z as a single surface plot', 'Dominating models and max z as heat map', 'Selected models in contour plot', - 'Selected models in interpolated contour plots', 'Measurement points'] + 'Selected models in interpolated contour plots', 'Measurement points', + 'Measurement points (3 parameters)'] layout = QVBoxLayout() diff --git a/extrap/gui/components/plot_formatting_options.py b/extrap/gui/components/plot_formatting_options.py index 259adf1..4a468fb 100644 --- a/extrap/gui/components/plot_formatting_options.py +++ b/extrap/gui/components/plot_formatting_options.py @@ -9,7 +9,8 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QFont -from PySide6.QtWidgets import QDialog, QFormLayout, QFontComboBox, QSpinBox, QDialogButtonBox, QLayout, QComboBox +from PySide6.QtWidgets import QDialog, QFormLayout, QFontComboBox, QSpinBox, QDialogButtonBox, QLayout, QComboBox, \ + QCheckBox, QWidget, QHBoxLayout from extrap.gui.components.model_color_map import ModelColorMap @@ -19,6 +20,9 @@ class PlotFormattingOptions: font_family: str = 'Arial' font_size: int = 10 legend_font_size: int = 6 + log_x: bool = False + log_y: bool = False + log_z: bool = False class PlotFormattingDialog(QDialog): @@ -52,6 +56,20 @@ def __init__(self, options: PlotFormattingOptions, parent=None, f=..., model_col self._colormap_selector.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) layout.addRow("Colormap", self._colormap_selector) + log_axes_widget = QWidget() + log_axes_layout = QHBoxLayout(log_axes_widget) + log_axes_layout.setContentsMargins(0, 0, 0, 0) + self._log_x_selector = QCheckBox("X") + self._log_x_selector.setChecked(self._options.log_x) + self._log_y_selector = QCheckBox("Y") + self._log_y_selector.setChecked(self._options.log_y) + self._log_z_selector = QCheckBox("Z") + self._log_z_selector.setChecked(self._options.log_z) + log_axes_layout.addWidget(self._log_x_selector) + log_axes_layout.addWidget(self._log_y_selector) + log_axes_layout.addWidget(self._log_z_selector) + layout.addRow("Logarithmic axes", log_axes_widget) + _dialog_buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) _dialog_buttons.accepted.connect(self.accept) _dialog_buttons.rejected.connect(self.reject) @@ -64,5 +82,8 @@ def accept(self) -> None: self._options.font_size = self._font_size_selector.value() self._options.legend_font_size = self._legend_font_size_selector.value() self._model_color_map.set_colormap(self._colormap_selector.currentText()) + self._options.log_x = self._log_x_selector.isChecked() + self._options.log_y = self._log_y_selector.isChecked() + self._options.log_z = self._log_z_selector.isChecked() super().accept() diff --git a/extrap/gui/plots/BaseGraphWidget.py b/extrap/gui/plots/BaseGraphWidget.py index 9fb479b..576084a 100644 --- a/extrap/gui/plots/BaseGraphWidget.py +++ b/extrap/gui/plots/BaseGraphWidget.py @@ -35,6 +35,7 @@ def __init__(self, graphWidget, main_widget: MainWidget, width=5, height=4, dpi= QSizePolicy.Expanding) super().updateGeometry() self.draw_figure() + self._apply_log_axes() def redraw(self): with matplotlib.rc_context({'font.family': self.main_widget.plot_formatting_options.font_family, @@ -42,9 +43,59 @@ def redraw(self): rotation = self._save_rotation() self.fig.clear() self.draw_figure() + self._apply_log_axes() self._restore_rotation(rotation) self.fig.canvas.draw_idle() + def _apply_log_axes(self): + """ Applies the logarithmic axis settings from the plot formatting options to every + plot Axes in the figure (skipping colorbars, which have no meaningful data scale). + """ + options = self.main_widget.plot_formatting_options + for ax in self.fig.axes: + if ax.get_label() == '': + continue + ax.set_xscale('log' if options.log_x else 'linear') + ax.set_yscale('log' if options.log_y else 'linear') + if hasattr(ax, 'set_zscale'): + ax.set_zscale('log' if options.log_z else 'linear') + + def get_controls_widget(self): + """ Returns an optional widget with extra, plot-specific controls (e.g. filters) + to be shown alongside the plot, or None if the plot has none. + """ + return None + + def redraw_preserving_limits(self): + """ Like redraw(), but keeps the current x/y/z axis limits (e.g. from a manual + zoom) instead of letting them auto-rescale to whatever is drawn this time. + Intended for redraws triggered by a pure display filter (show/hide something) + rather than by an actual change of the underlying data or its range. + """ + limits = self._save_axis_limits() + self.redraw() + self._restore_axis_limits(limits) + self.fig.canvas.draw_idle() + + def _save_axis_limits(self): + limits = [] + for ax in self.fig.axes: + if ax.get_label() == '': + limits.append(None) + continue + limits.append((ax.get_xlim(), ax.get_ylim(), ax.get_zlim() if hasattr(ax, 'get_zlim') else None)) + return limits + + def _restore_axis_limits(self, limits): + for ax, entry in zip(self.fig.axes, limits): + if entry is None: + continue + xlim, ylim, zlim = entry + ax.set_xlim(xlim) + ax.set_ylim(ylim) + if zlim is not None and hasattr(ax, 'set_zlim'): + ax.set_zlim(zlim) + def _save_rotation(self): return [(ax.elev, ax.azim) if isinstance(ax, Axes3D) else (None, None) for ax in self.fig.axes] @@ -74,9 +125,12 @@ def getPixelGap(lowerlimit, upperlimit, numberOfPixels): pixelGap = (upperlimit - lowerlimit) / numberOfPixels return pixelGap - def calculate_z_optimized(self, X, Y, function): + def calculate_z_optimized(self, X, Y, function, param_overrides=None): """ This function evaluates the function passed to it. + param_overrides can be used to override the fixed value of a non-axis + parameter (e.g. to evaluate the model at a specific value of a third + parameter instead of the one selected in the UI). """ xs, ys = X.reshape(-1), Y.reshape(-1) points = np.ndarray((len(self.main_widget.data_display.parameters), len(xs))) @@ -84,6 +138,9 @@ def calculate_z_optimized(self, X, Y, function): parameter_value_list = self.main_widget.data_display.getValues() for p, v in parameter_value_list.items(): points[p] = v + if param_overrides: + for p, v in param_overrides.items(): + points[p] = v param1 = self.main_widget.data_display.getAxisParameter(0).id param2 = self.main_widget.data_display.getAxisParameter(1).id if param1 >= 0: diff --git a/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py b/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py index 6984c5f..fad6b9c 100644 --- a/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py +++ b/extrap/gui/plots/MultiParameterMeasurementPointsPlotWidget.py @@ -7,6 +7,7 @@ import numpy as np from PySide6.QtGui import QColor +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QCheckBox, QPushButton, QComboBox from matplotlib import patches as mpatches from extrap.gui.plots.BaseGraphWidget import GraphDisplayWindow @@ -27,8 +28,22 @@ class MultiParameterMeasurementPointsPlot(GraphDisplayWindow): """ def __init__(self, graphWidget, main_widget, width=5, height=4, dpi=100): + # Name of the parameter currently used to split the data into separate surfaces; + # None means "not chosen yet", in which case draw_figure() defaults to the first + # parameter that isn't shown on the X/Y axis. + self._series_parameter_name = None + # Which values of the chosen parameter to show; missing keys default to visible. + self._value_visible = {} + self._controls_widget = None + self._controls_layout = None super().__init__(graphWidget, main_widget, width, height, dpi) + def get_controls_widget(self): + """ Returns the widget with the parameter selector, the value checkboxes, and the + fit-to-data button, or None if it hasn't been built yet (before the first draw). + """ + return self._controls_widget + def draw_figure(self): """ This function draws the graph @@ -42,27 +57,11 @@ def draw_figure(self): # Get max x and max y value as a initial default value or a value provided by user maxX, maxY = self.get_max() - # Get the callpath color map - widget = self.main_widget - dict_callpath_color = widget.model_color_map - # Get base data for drawing points parameter_x = self.main_widget.data_display.getAxisParameter(0) parameter_y = self.main_widget.data_display.getAxisParameter(1) parameters = self.main_widget.data_display.parameters - # The third parameter is the first parameter that is not shown on an axis. - # Note: parameter_x.id/parameter_y.id are *positions* within `parameters` - # (as assigned by the axis combo boxes), not the parameters' own global ids, - # so the series parameter and its coordinate index must be found by position too. - series_parameter = None - series_parameter_index = None - for i, p in enumerate(parameters): - if i != parameter_x.id and i != parameter_y.id: - series_parameter = p - series_parameter_index = i - break - # Set the x_label and y_label based on parameter selected. x_label = parameter_x.name if x_label.startswith("_"): @@ -73,26 +72,70 @@ def draw_figure(self): ax_all = self.fig.add_subplot(1, 1, 1, projection='3d') - if series_parameter is None: + # Candidates for the "surfaces by" parameter: everything not already shown on the + # X/Y axis. Like parameter_x.id/parameter_y.id, each index here is a *position* + # within `parameters` (as assigned by the axis combo boxes), not the parameter's + # own global id, so the coordinate index must be read the same way. + candidate_parameters = [(i, p) for i, p in enumerate(parameters) + if i != parameter_x.id and i != parameter_y.id] + + if not candidate_parameters: ax_all.text2D(0.5, 0.5, "This plot requires an experiment with at least 3 parameters", transform=ax_all.transAxes, ha='center') + if self._controls_widget is not None: + self._controls_widget.setVisible(False) return - # Determine the discrete values of the third parameter that occur in the measurements + # Which parameter is used to split the data into surfaces is chosen via our own + # dropdown (see _update_controls()), not via the Graph limits panel - its value + # has no meaning here, since every measured value of it is shown at once, so a + # fixed-value selector there wouldn't make sense. Default to the first candidate + # until the user picks one, and fall back to it if the chosen name is no longer + # a candidate (e.g. it just got assigned to the X or Y axis instead). + candidate_names = [p.name for _, p in candidate_parameters] + if self._series_parameter_name not in candidate_names: + self._series_parameter_name = candidate_names[0] + series_parameter_index, series_parameter = next( + (i, p) for i, p in candidate_parameters if p.name == self._series_parameter_name) + + # The Graph limits panel shows a fixed-value selector for every parameter not on + # an axis, but the one currently chosen above is ignored by this plot (every + # measured value of it is shown at once), so its value field would be misleading. + # Hide just that one; any other leftover parameter's value selector still matters + # (it's used to evaluate the model) and stays visible. + for value_selection in self.main_widget.data_display.value_selections: + value_selection.setVisible(value_selection.parameter != series_parameter_index) + + # Determine the discrete values of the chosen parameter that occur in the measurements series_values = sorted({ m.coordinate[series_parameter_index] for model in model_list for m in model.measurements }) + self._update_controls(candidate_parameters, series_parameter, series_values) + # Only the values checked in the controls widget are actually drawn, but colors + # are assigned over the full set (see below) so shades don't shift as values are + # toggled on/off. + displayed_values = [v for v in series_values if self._value_visible.get(v, True)] + + # Preserve plotting order (and de-duplicate) so every plotted callpath gets its + # own color family, regardless of how many models are selected. + plotted_callpaths = list(dict.fromkeys(selected_callpaths)) + + # Every (callpath, value) combination gets its own, never-repeated color: the + # callpath picks a color family (e.g. "Blues"), the value picks a shade within + # it. This way selecting several models never reuses colors between surfaces, + # while surfaces from the same model still read as a related family. + surface_colors = self._compute_surface_colors(plotted_callpaths, series_values) + max_z = 0 for model, callpath in zip(model_list, selected_callpaths): - base_color = QColor(dict_callpath_color[callpath]) points = model.measurements - for value in series_values: + for value in displayed_values: value_points = [m for m in points if m.coordinate[series_parameter_index] == value] if not value_points: continue - color = self._shade_color(base_color, value, series_values) + color = surface_colors[(callpath, value)] if parameter_x.id >= 0: xs = np.array([m.coordinate[parameter_x.id] for m in value_points]) @@ -112,11 +155,12 @@ def draw_figure(self): if len(maximum) > 0: max_z = max(max_z, max(maximum)) - # Draw points - ax_all.scatter(xs, ys, mean, color=color, marker='x') - ax_all.scatter(xs, ys, median, color=color, marker='+') - ax_all.scatter(xs, ys, minimum, color=color, marker='_') - ax_all.scatter(xs, ys, maximum, color=color, marker='_') + # Draw points (larger than in the single-value "Measurement points" plot, + # since here they compete with several overlapping surfaces instead of one) + ax_all.scatter(xs, ys, mean, color=color, marker='x', s=60, linewidth=1.5) + ax_all.scatter(xs, ys, median, color=color, marker='+', s=60, linewidth=1.5) + ax_all.scatter(xs, ys, minimum, color=color, marker='_', s=60, linewidth=1.5) + ax_all.scatter(xs, ys, maximum, color=color, marker='_', s=60, linewidth=1.5) # Draw connecting line line_x, line_y, line_z = [], [], [] for x, y, min_v, max_v in zip(xs, ys, minimum, maximum): @@ -136,15 +180,19 @@ def draw_figure(self): with np.errstate(invalid='ignore', divide='ignore'): for model, callpath in zip(model_list, selected_callpaths): function = model.hypothesis.function - base_color = QColor(dict_callpath_color[callpath]) - for value in series_values: + for value in displayed_values: zs = self.calculate_z_optimized(X, Y, function, {series_parameter_index: value}) finite = zs[np.logical_not(np.isinf(zs))] if finite.size: max_z = max(max_z, np.max(finite)) zs[np.isinf(zs)] = max_z Z = zs.reshape(X.shape) - color = self._shade_color(base_color, value, series_values) + color = surface_colors[(callpath, value)] + # No edgecolor: a colored wireframe (rstride=cstride=1 means one + # line per grid cell) turns into dense clutter that buries the + # sparse scatter points, which is much worse with several + # overlapping surfaces (one per value) than with the single + # surface per model the original "Measurement points" plot draws. ax_all.plot_surface(X, Y, Z, color=color, rstride=1, cstride=1, antialiased=False, alpha=0.1) @@ -156,35 +204,106 @@ def draw_figure(self): ax_all.set_ylabel('\n' + y_label, linespacing=3.1) ax_all.set_zlabel( '\n' + self.main_widget.get_selected_metric().name, linespacing=3.1) - ax_all.set_title("Measurement points") + ax_all.set_title("Measurement points (3 parameters)") - self._draw_legend(ax_all, dict_callpath_color, series_parameter, series_values) + self._draw_legend(ax_all, plotted_callpaths, series_parameter, displayed_values, surface_colors) - @staticmethod - def _shade_color(base_color: QColor, value, series_values): - """ Shades the callpath's base color from darker (lowest value of the - third parameter) to lighter (highest value), so that surfaces for - the same callpath but different parameter values stay visually - distinguishable while remaining recognizable as the same callpath. + def _compute_surface_colors(self, plotted_callpaths, series_values): + """ Assigns every (callpath, value) combination its own, unique color, drawn from + Extra-P's currently selected colormap (Plot formatting options -> Colormap) + instead of a separate hardcoded palette, so this plot's colors stay consistent + with the rest of the application. Colors are handed out across all (callpath, + value) pairs together (not one color per callpath, reused across values), so + no two surfaces are ever assigned the same color; if there are more pairs than + colors in the palette, it wraps around with progressively lighter shades, the + same way ModelColorMap extends its palette for extra callpaths elsewhere. """ - if len(series_values) <= 1: - return base_color.name() - position = series_values.index(value) / (len(series_values) - 1) - factor = 60 + position * 120 # 60 (darker) .. 180 (lighter) - return base_color.lighter(int(factor)).name() + color_list = self.main_widget.model_color_map.color_list + pairs = [(callpath, value) for callpath in plotted_callpaths for value in series_values] + + surface_colors = {} + for i, pair in enumerate(pairs): + if i < len(color_list): + surface_colors[pair] = color_list[i] + else: + offset = i % len(color_list) + multiple = i // len(color_list) + surface_colors[pair] = QColor(color_list[offset]).lighter(100 + 20 * multiple).name() + return surface_colors + + def _update_controls(self, candidate_parameters, series_parameter, series_values): + """ (Re-)builds the controls row: a dropdown to choose which parameter is split + into separate surfaces, checkboxes to show/hide its individual values + (keeping the on/off state of values that still occur in the data and + defaulting newly seen values to visible), and a button to fit the view back + to the currently shown data. + """ + if self._controls_widget is None: + self._controls_widget = QWidget() + self._controls_layout = QHBoxLayout(self._controls_widget) + self._controls_layout.setContentsMargins(4, 2, 4, 2) + else: + self._controls_widget.setVisible(True) + while self._controls_layout.count(): + item = self._controls_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + self._controls_layout.addWidget(QLabel("Surfaces by:")) + series_combo = QComboBox() + for _, p in candidate_parameters: + series_combo.addItem(p.name) + series_combo.setCurrentText(series_parameter.name) + series_combo.currentTextChanged.connect(self._on_series_parameter_changed) + self._controls_layout.addWidget(series_combo) + + self._controls_layout.addWidget(QLabel(f"Show {series_parameter.name}:")) + for value in series_values: + checkbox = QCheckBox(f"{value:g}") + checkbox.setChecked(self._value_visible.get(value, True)) + checkbox.toggled.connect(lambda checked, v=value: self._on_value_toggled(v, checked)) + self._controls_layout.addWidget(checkbox) + self._controls_layout.addStretch(1) + fit_button = QPushButton("Fit view to data") + fit_button.setToolTip("Reset the zoom/pan and rescale the axes to fit the currently shown data") + fit_button.clicked.connect(self._on_fit_to_data) + self._controls_layout.addWidget(fit_button) + + def _on_series_parameter_changed(self, name): + if name == self._series_parameter_name: + return + self._series_parameter_name = name + # Visibility state belongs to the previous parameter's values; a same-numbered + # value of the newly chosen parameter isn't necessarily related to it. + self._value_visible = {} + # Switching which parameter creates the surfaces changes the data range + # entirely, so let the axes auto-scale to the new data instead of preserving + # the old view. + self.redraw() + + def _on_value_toggled(self, value, checked): + self._value_visible[value] = checked + # Showing/hiding a value doesn't change the underlying data range, so keep + # whatever axis limits are currently shown (e.g. from a manual zoom) instead of + # letting matplotlib auto-rescale to only what's now drawn. + self.redraw_preserving_limits() + + def _on_fit_to_data(self): + # A plain redraw() (unlike redraw_preserving_limits()) lets the axes auto-scale + # to whatever is currently drawn, i.e. fits the view to the visible data again. + self.redraw() - def _draw_legend(self, ax_all, dict_callpath_color, series_parameter, series_values): + def _draw_legend(self, ax_all, plotted_callpaths, series_parameter, series_values, surface_colors): patches = [] - for callpath, color in dict_callpath_color.items(): - base_color = QColor(color) + for callpath in plotted_callpaths: + label = str(callpath.name) + if label.startswith("_"): + label = label[1:] + label = replace_method_parameters(label) for value in series_values: - shade = self._shade_color(base_color, value, series_values) - label = str(callpath.name) - if label.startswith("_"): - label = label[1:] - label = replace_method_parameters(label) - label = f"{label} ({series_parameter.name}={value:g})" - patches.append(mpatches.Patch(color=shade, label=label)) + value_label = f"{label} ({series_parameter.name}={value:g})" + patches.append(mpatches.Patch(color=surface_colors[(callpath, value)], label=value_label)) leg = ax_all.legend(handles=patches, fontsize=self.main_widget.plot_formatting_options.legend_font_size, loc="upper right", bbox_to_anchor=(1, 1)) if leg: