From cdd8c980d6d22c6b0f1eaf2410220aeb3a24bc28 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Thu, 25 Jun 2026 14:18:37 +0100 Subject: [PATCH] Adds a trust bar for size distribution result plots --- .../SizeDistributionPerspective.py | 11 ++ src/sas/qtgui/Plotting/Plotter.py | 7 + src/sas/qtgui/Plotting/PlotterData.py | 3 + src/sas/qtgui/Plotting/TrustBar.py | 184 ++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 src/sas/qtgui/Plotting/TrustBar.py diff --git a/src/sas/qtgui/Perspectives/SizeDistribution/SizeDistributionPerspective.py b/src/sas/qtgui/Perspectives/SizeDistribution/SizeDistributionPerspective.py index 2efdbc1923..42b714471b 100644 --- a/src/sas/qtgui/Perspectives/SizeDistribution/SizeDistributionPerspective.py +++ b/src/sas/qtgui/Perspectives/SizeDistribution/SizeDistributionPerspective.py @@ -635,15 +635,26 @@ def fitComplete(self, result: MaxEntResult) -> None: plots = [self._model_item] qmin_fit = float(self.txtMinRange.text()) qmax_fit = float(self.txtMaxRange.text()) + self.size_distr_plot, self.trust_plot = self.logic.newSizeDistrPlot(result, qmin_fit, qmax_fit) if self.size_distr_plot is not None: + # set the trust range for the size distribution plot + self.size_distr_plot.show_trust_bar = True + trust_range = self.logic.computeTrustRange(qmin_fit, qmax_fit) + self.size_distr_plot.trust_range = { + "d_low": trust_range[0], + "d_high": trust_range[1], + } + title = self.size_distr_plot.name GuiUtils.updateModelItemWithPlot(self._model_item, self.size_distr_plot, title) plots.append(self.size_distr_plot) + if self.trust_plot is not None: title = self.trust_plot.name GuiUtils.updateModelItemWithPlot(self._model_item, self.trust_plot, title) plots.append(self.trust_plot) + self.communicator.plotRequestedSignal.emit(plots) # add fit to data plot diff --git a/src/sas/qtgui/Plotting/Plotter.py b/src/sas/qtgui/Plotting/Plotter.py index 998baac4ee..702a1aa110 100644 --- a/src/sas/qtgui/Plotting/Plotter.py +++ b/src/sas/qtgui/Plotting/Plotter.py @@ -22,6 +22,7 @@ from sas.qtgui.Plotting.QRangeSlider import QRangeSlider from sas.qtgui.Plotting.ScaleProperties import ScaleProperties from sas.qtgui.Plotting.SetGraphRange import SetGraphRange +from sas.qtgui.Plotting.TrustBar import TrustBar logger = logging.getLogger(__name__) @@ -42,6 +43,8 @@ def __init__(self, parent=None, manager=None, quickplot=False): # Dictionary of slider interactors {plot_id:interactor} self.sliders = {} + self.trust_bar = TrustBar(self.ax, self.canvas) + # Window for text add self.addText = AddText(self) @@ -324,6 +327,10 @@ def plot(self, data=None, color=None, marker=None, hide_error=False, transform=T sliders.toggle() self.sliders[data.name] = sliders + # Draw size-distribution trust bar if requested. + if data.show_trust_bar: + self.trust_bar.draw(data) + # refresh canvas self.canvas.draw_idle() diff --git a/src/sas/qtgui/Plotting/PlotterData.py b/src/sas/qtgui/Plotting/PlotterData.py index 5b3f863673..9299b7f911 100644 --- a/src/sas/qtgui/Plotting/PlotterData.py +++ b/src/sas/qtgui/Plotting/PlotterData.py @@ -83,6 +83,9 @@ def __init__(self, x=None, y=None, dx=None, dy=None): self.slider_high_q_setter = [] # List of attributes that lead to a setter to tie a high Q method to the slider self.slider_high_q_getter = [] # List of attributes that lead to a getter to tie a high Q method to the slider + # Trust bar for size distribution perspective + self.show_trust_bar = False # Should the trust bar be shown? + def setSlicerOwner(self, owner): """ Store the 2D plot window that owns a slicer-generated 1D plot. diff --git a/src/sas/qtgui/Plotting/TrustBar.py b/src/sas/qtgui/Plotting/TrustBar.py new file mode 100644 index 0000000000..c46fe7fbd7 --- /dev/null +++ b/src/sas/qtgui/Plotting/TrustBar.py @@ -0,0 +1,184 @@ +"""Trust bar for displaying trust information above the results plot for Size Distribution perspective.""" + + +import numpy as np +from matplotlib.axes import Axes +from matplotlib.backend_bases import FigureCanvasBase +from matplotlib.colors import LinearSegmentedColormap + +from sas.qtgui.Plotting.PlotterData import Data1D + +ColourStop = tuple[float, str] + + +class TrustBar: + + def __init__(self, ax: Axes, canvas: FigureCanvasBase) -> None: + self.ax = ax + self.canvas = canvas + self.data: Data1D | None = None + self.bar_ax: Axes | None = None + self.xlim_cid: int | None = None # To store the callback ID for disconnecting later + + def draw(self, data: Data1D) -> None: + """ + Draw a simple green-yellow-red gradient bar above the main 1D plot. + + :param data: The Data1D object containing the trust range information. + """ + + # Clear any existing trust bar before drawing a new one. + self.clear() + + self.data = data + + trust_range = getattr(data, "trust_range", None) + if trust_range is None: + return + + # Get the current x limits of the main plot. + xmin, xmax = self.ax.get_xlim() + + d_low = self._normalize_x(trust_range["d_low"], xmin, xmax) + d_high = self._normalize_x(trust_range["d_high"], xmin, xmax) + + stops = self._make_colour_stops(d_low, d_high) + + # Create a custom colormap from the colour stops. + cmap = LinearSegmentedColormap.from_list("trust_bar_gradient", stops) + + # Create a gradient image for the trust bar. + gradient = np.linspace(0, 1, 256).reshape(1, -1) + + # Create a thin inset axis above the main plot. + bar_ax = self.ax.inset_axes((0.0, 1.02, 1.0, 0.02), transform=self.ax.transAxes) + + bar_ax.imshow(gradient, aspect="auto", cmap=cmap, extent=(xmin, xmax, 0, 1), origin="lower") + + # Match main plot x scaling and range. + bar_ax.set_xscale(self.ax.get_xscale()) + bar_ax.set_xlim(self.ax.get_xlim()) + + # Remove ticks and spines for a clean look. + bar_ax.set_yticks([]) + bar_ax.set_xticks([]) + bar_ax.minorticks_off() + for spine in bar_ax.spines.values(): + spine.set_visible(False) + + # Store the bar axis for later removal and connect the xlim_changed callback + # to update the trust bar when the main plot is updated. + self.xlim_cid = self.ax.callbacks.connect("xlim_changed", self.update) + + def clear(self) -> None: + """ + Clear the trust bar from the plot. This method disconnects the xlim_changed callback + and removes the bar axis if it exists. + """ + if self.xlim_cid is not None: + try: + self.ax.callbacks.disconnect(self.xlim_cid) + except Exception: + pass + self.xlim_cid = None + + if self.bar_ax is not None: + try: + self.bar_ax.remove() + except ValueError: + pass + self.bar_ax = None + + self.data = None + + def update(self, main_ax) -> None: + """ + Update the trust bar when the main plot is updated. + """ + if self.data is not None: + self.draw(self.data) + self.canvas.draw_idle() + + def _normalize_x(self, x: float, xmin: float, xmax: float) -> float: + """Returns the normalized x value in the range [0, 1] based on the current x limits.""" + return (x - xmin) / (xmax - xmin) + + def _make_colour_stops(self, low: float, high: float, transition_width: float = 0.05) -> list[ColourStop]: + """Returns a list of colour stops for the trust bar gradient based on the low and high thresholds. + + red ---- yellow ---- green ---- yellow ---- red + (low) (high) + + This function also handles cases where the low and high thresholds are outside the [0, 1] range. + + :param low: The lower threshold for the trust bar (normalized to [0, 1]). + :param high: The upper threshold for the trust bar (normalized to [0, 1]). + :param transition_width: The width of the transition zone between colours. Default is 0.05. + :return: A list of tuples representing the colour stops for the gradient. + """ + + stops: list[ColourStop] = [] + + # Lower boundary: red to yellow to green + if low - transition_width >= 1.0: + return [(0.0, "red"), (1.0, "red")] + elif low + transition_width <= 0.0: + stops.append((0.0, "green")) + elif low <= 0.0: + stops.extend( + [ + (0.0, "yellow"), + (low + transition_width, "green"), + ] + ) + else: + stops.extend( + [ + (0.0, "red"), + (low - transition_width, "red") if low - transition_width > 0.0 else (0.0, "red"), + (low, "yellow"), + (low + transition_width, "green"), + ] + ) + + # Upper boundary: green to yellow to red + if high + transition_width <= 0.0: + return [(0.0, "red"), (1.0, "red")] + elif high + transition_width >= 1.0: + stops.append((1.0, "green")) + elif high >= 1.0: + stops.extend( + [ + (high - transition_width, "green"), + (1.0, "yellow"), + ] + ) + else: + stops.extend( + [ + (high - transition_width, "green"), + (high, "yellow"), + (high + transition_width, "red") if high + transition_width < 1.0 else (1.0, "red"), + (1.0, "red"), + ] + ) + + # Sort the stops by position to ensure they are in the correct order for the colormap. + stops = sorted(stops, key=lambda x: x[0]) + + # Remove duplicate and close stops to avoid issues with the colormap. + clean_stops: list[ColourStop] = [] + for position, colour in stops: + if clean_stops and abs(clean_stops[-1][0] - position) < 1e-12: + clean_stops[-1] = (position, colour) + else: + clean_stops.append((position, colour)) + + # Handle the case where there is only one stop, which can cause issues with the colormap. + if len(clean_stops) == 1: + clean_stops = [ + (0.0, clean_stops[0][1]), + (1.0, clean_stops[0][1]), + ] + + return clean_stops