Source code for nectarchain.trr_test_suite.gui

import argparse
import os
import pickle
import sys
import tempfile

from matplotlib.backends.backend_qt5 import NavigationToolbar2QT as NavigationToolbar
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas

# from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt5.QtCore import QProcess, QTimer
from PyQt5.QtWidgets import (
    QApplication,
    QComboBox,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QSpacerItem,
    QTextEdit,
    QVBoxLayout,
    QWidget,
    QWidgetItem,
)

import nectarchain.trr_test_suite.deadtime as deadtime
import nectarchain.trr_test_suite.linearity as linearity
import nectarchain.trr_test_suite.pedestal as pedestal
import nectarchain.trr_test_suite.pix_tim_uncertainty as pix_tim_uncertainty
import nectarchain.trr_test_suite.trigger_timing as trigger_timing
from nectarchain.trr_test_suite import (
    pix_couple_tim_uncertainty as pix_couple_tim_uncertainty,
)

# Ensure the src directory is in sys.path
test_dir = os.path.abspath("src")
if test_dir not in sys.path:
    sys.path.append(test_dir)

# Import test modules


[docs] class TestRunner(QWidget): """The ``TestRunner`` class is a GUI application that allows the\ user to run various tests and display the results. The class provides the following functionality: - Allows the user to select a test from a dropdown menu. - Dynamically generates input fields based on the selected test. - Runs the selected test and displays the output in a text box. - Displays the test results in a plot canvas, with navigation buttons\ to switch between multiple plots. - Provides a dark-themed UI with custom styling for various UI elements. The class uses the PyQt5 library for the GUI implementation and the Matplotlib\ library for plotting the test results. """ test_modules = { "Linearity Test": linearity, "Deadtime Test": deadtime, "Pedestal Test": pedestal, "Pixel Time Uncertainty Test": pix_tim_uncertainty, "Time Uncertainty Between Couples of Pixels": pix_couple_tim_uncertainty, "Trigger Timing Test": trigger_timing, } def __init__(self): super().__init__() self.params = {} self.process = None self.plot_files = [] # Store the list of plot files self.current_plot_index = 0 # Index to track which plot is being displayed self.figure = Figure(figsize=(8, 6)) self.init_ui() def init_ui(self): # Main layout: vertical, dividing into two sections (top for controls/plot # , bottom for output) main_layout = QVBoxLayout() self.setStyleSheet( """ QWidget { background-color: #2e2e2e; /* Dark background */ color: #ffffff; /* Light text */ } QLabel { font-weight: bold; color: #ffffff; /* Light text */ } QComboBox { background-color: #444444; /* Dark combo box */ color: #ffffff; /* Light text */ border: 1px solid #888888; /* Light border */ min-width: 200px; /* Set a minimum width */ } QLineEdit { background-color: #444444; /* Dark input field */ color: #ffffff; /* Light text */ border: 1px solid #888888; /* Light border */ padding: 5px; /* Add padding */ min-width: 200px; /* Fixed width */ } QTextEdit { background-color: #1e1e1e; /* Dark output box */ color: #ffffff; /* Light text */ border: 1px solid #888888; /* Light border */ padding: 5px; /* Add padding */ min-width: 800px; /* Set a minimum width to match the canvas */ } QTextEdit:focus { border: 1px solid #00ff00; /* Green border on focus for visibility */ } QPushButton { background-color: #4caf50; /* Green button */ color: white; /* White text */ border: none; /* No border */ padding: 10px; /* Add padding */ border-radius: 5px; /* Rounded corners */ } QPushButton:disabled { background-color: rgba(76, 175, 80, 0.5); /* Transparent green when\ disabled */ color: rgba(255, 255, 255, 0.5); /* Light text when disabled */ } QPushButton:hover { background-color: #45a049; /* Darker green on hover */ } """ ) # Horizontal layout for test options (left) and plot canvas (right) top_layout = QHBoxLayout() # Create a QGroupBox for controls controls_box = QGroupBox("Test Controls", self) controls_box.setFixedHeight(600) controls_layout = QVBoxLayout() # Layout for the controls # Dropdown for selecting the test self.label = QLabel("Select Test:", self) self.label.setFixedSize(100, 20) controls_layout.addWidget(self.label) self.test_selector = QComboBox(self) self.test_selector.addItem("Select Test") self.test_selector.addItems( [ "Linearity Test", "Deadtime Test", "Pedestal Test", "Pixel Time Uncertainty Test", "Time Uncertainty Between Couples of Pixels", "Trigger Timing Test", ] ) self.test_selector.setFixedWidth(400) # Fixed width for the dropdown self.test_selector.currentIndexChanged.connect(self.update_parameters) controls_layout.addWidget(self.test_selector) # Container for parameter fields self.param_widgets = QWidget(self) self.param_widgets.setFixedSize(400, 400) self.param_layout = QVBoxLayout(self.param_widgets) controls_layout.addWidget(self.param_widgets) # Button to run the test self.run_button = QPushButton("Run Test", self) # Disable the run button initially self.run_button.setEnabled(False) self.run_button.clicked.connect(self.run_test) controls_layout.addWidget(self.run_button) # Set the controls layout to the group box controls_box.setLayout(controls_layout) # Add the controls box to the top layout (left side) top_layout.addWidget(controls_box) # Add a stretchable spacer to push the canvas to the right top_layout.addSpacerItem( QSpacerItem( 40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum ) ) # Create a vertical layout for the plot container self.plot_container = QWidget(self) self.plot_layout = QVBoxLayout(self.plot_container) # Fixed size for the container self.plot_container.setFixedSize( 800, 650 ) # Set desired fixed size for the container # Create a vertical layout for the plot (canvas and toolbar) self.canvas = FigureCanvas(self.figure) self.canvas.setFixedSize(800, 600) # Fixed size for the canvas # Add toolbar for zooming and panning self.toolbar = NavigationToolbar(self.canvas, self) self.toolbar.setFixedHeight(50) # Fixed height for the toolbar # Add the toolbar and canvas to the plot layout self.plot_layout.addWidget(self.toolbar) # Toolbar stays on top self.plot_layout.addWidget(self.canvas) # Canvas below toolbar # Add the plot container to the top layout (to the right) top_layout.addWidget(self.plot_container) # Add the top layout (controls + canvas) to the main layout main_layout.addLayout(top_layout) # Navigation buttons nav_layout = QHBoxLayout() self.prev_button = QPushButton("Previous Plot", self) self.prev_button.clicked.connect(self.show_previous_plot) self.prev_button.setEnabled(False) # Initially disabled nav_layout.addWidget(self.prev_button) self.next_button = QPushButton("Next Plot", self) self.next_button.clicked.connect(self.show_next_plot) self.next_button.setEnabled(False) # Initially disabled nav_layout.addWidget(self.next_button) main_layout.addLayout(nav_layout) # Output text box (bottom section of the main layout) self.output_text_edit = QTextEdit(self) self.output_text_edit.setReadOnly(True) # To prevent user editing self.output_text_edit.setFixedHeight( 150 ) # Set a fixed height for the output box self.output_text_edit.setMinimumWidth( 800 ) # Set a minimum width to match the canvas main_layout.addWidget(self.output_text_edit) # Set the main layout to the window self.setLayout(main_layout) self.setWindowTitle("Test Runner GUI") self.showFullScreen() def get_parameters_from_module(self, module): # Fetch parameters from the module if hasattr(module, "get_args"): parser = module.get_args() params = {} for arg in parser._actions: if isinstance(arg, argparse._StoreAction): params[arg.dest] = { "default": arg.default, "help": arg.help, # Store the help text } return params else: raise RuntimeError("No get_args function found in module.") def debug_layout(self): for i in range(self.param_layout.count()): item = self.param_layout.itemAt(i) widget = item.widget() if widget: print(f"Widget in layout: {widget.objectName()}") def update_parameters(self): # Clear existing parameter fields for i in reversed(range(self.param_layout.count())): item = self.param_layout.itemAt(i) if isinstance( item, QHBoxLayout ): # Check if the item is a QHBoxLayout (contains label and help button) for j in reversed(range(item.count())): widget = item.itemAt(j).widget() if widget: widget.deleteLater() elif isinstance(item, QWidgetItem): # For direct widgets like QLineEdit widget = item.widget() if widget: widget.deleteLater() # Remove the item itself from the layout self.param_layout.removeItem(item) # Get the selected test and corresponding module selected_test = self.test_selector.currentText() # If the placeholder is selected, do nothing if selected_test == "Select Test": self.run_button.setEnabled(False) return module = self.test_modules.get(selected_test) if module: try: self.params = self.get_parameters_from_module(module) for param, param_info in self.params.items(): if param == "temp_output": # Skip temp_output continue # Create a horizontal layout for the label and help button param_layout = QHBoxLayout() # Create label label = QLabel(f"{param}:", self) param_layout.addWidget(label) # Create tiny grey circle help button with a white question mark help_button = QPushButton("?", self) help_button.setFixedSize(16, 16) # Smaller button size help_button.setStyleSheet( """ QPushButton { background-color: grey; color: white; border-radius: 8px; /* Circular button */ font-weight: bold; font-size: 10px; /* Smaller font size */ } QPushButton:hover { background-color: darkgrey; /* Change color on hover */ } """ ) help_button.setToolTip(param_info["help"]) # # Use lambda to capture the current param's help text # help_button.clicked.connect(lambda _, p=param_info["help"]: # self.show_help(p)) # Add the help button to the layout (next to the label) param_layout.addWidget(help_button) param_layout.addStretch() # Add stretch to push the help button to # the right # Add the horizontal layout (label + help button) to the main layout self.param_layout.addLayout(param_layout) # Create the input field for the parameter entry = QLineEdit(self) entry.setText( str(param_info["default"]) .replace("[", "") .replace("]", "") .replace(",", "") ) entry.setObjectName(param) entry.setFixedWidth(400) # Set fixed width for QLineEdit self.param_layout.addWidget(entry) # Force update the layout self.param_widgets.update() QTimer.singleShot( 0, self.param_widgets.update ) # Ensures the layout is updated self.run_button.setEnabled(True) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to fetch parameters: {e}") else: QMessageBox.critical(self, "Error", "No test selected or test not found") def show_help(self, help_text): QMessageBox.information(self, "Parameter Help", help_text) def run_test(self): # Clean up old plot files to avoid loading leftover files self.cleanup_tempdir() selected_test = self.test_selector.currentText() module = self.test_modules.get(selected_test) if module: params = [] self.update() self.repaint() # Generate temporary output path self.temp_output = tempfile.gettempdir() # print(f"Temporary output dir: {self.temp_output}") # Debug print for param, _ in self.params.items(): widget_list = self.param_widgets.findChildren(QLineEdit, param) if widget_list: widget = widget_list[0] params.append(f"--{param}") params.extend(widget.text().split(" ")) if param == "output": params.append(f"--output={widget.text()}") params.append(f"--temp_output={self.temp_output}") else: print(f"Widget with name {param} not found") test_script_path = os.path.abspath(module.__file__) command = [sys.executable, test_script_path] + params print(f"Command to run: {command}") # Debug print try: self.output_text_edit.clear() self.process = QProcess(self) self.process.setProcessChannelMode( QProcess.ProcessChannelMode.MergedChannels ) self.process.readyReadStandardOutput.connect(self.read_process_output) self.process.finished.connect(self.process_finished) QTimer.singleShot( 0, lambda: self.process.start( sys.executable, [test_script_path] + params ), ) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to run the test: {e}") else: QMessageBox.critical( self, "Error", "No parameters found for the selected test" ) self.plot_files = [ os.path.join(self.temp_output, f"plot{i}.pkl") for i in range(1, 3) ]
[docs] def read_process_output(self): """Reads and displays the process output in real-time.""" if self.process: output = self.process.readAllStandardOutput().data().decode("utf-8").strip() if output: self.output_text_edit.append(output)
[docs] def process_finished(self): """Handle the process when it finishes.""" if self.process.exitCode() == 0: QMessageBox.information(self, "Test Output", "Test completed successfully.") # Delay to ensure file creation is complete QTimer.singleShot(1000, self.check_and_display_plot) else: QMessageBox.critical( self, "Error", f"Test failed with exit code {self.process.exitCode()}" )
def check_and_display_plot(self): plot_files = [ os.path.join(self.temp_output, f"plot{i}.pkl") for i in range(1, 3) ] self.display_plot([f for f in plot_files if os.path.exists(f)])
[docs] def display_plot(self, plot_files): """Loads the plots from the pickle files and displays them on the canvas.""" self.plots = [] self.current_plot_index = 0 # Load all available plots from the pickle files for plot_file in plot_files: with open(plot_file, "rb") as f: self.plots.append(pickle.load(f)) # Load the plot data # Display the first plot if there are any loaded plots if self.plots: self.update_plot_canvas() # Enable the "Next" button if there is more than one plot if len(self.plots) > 1: self.next_button.setEnabled(True) else: self.next_button.setEnabled(False)
[docs] def update_plot_canvas(self): """Updates the canvas with the current plot.""" if not self.plots: return try: # Load the current figure with open(self.plot_files[self.current_plot_index], "rb") as f: loaded_figure = pickle.load(f) # loaded_figure = self.plots[self.current_plot_index] # Remove the old canvas and toolbar from the plot layout self.plot_layout.removeWidget(self.canvas) self.canvas.deleteLater() # Properly delete the old canvas self.plot_layout.removeWidget(self.toolbar) self.toolbar.deleteLater() # Properly delete the old toolbar # Create a new canvas with the loaded figure self.canvas = FigureCanvas(loaded_figure) self.canvas.setFixedSize(800, 600) # Set a fixed size for the canvas # Adjust the plot margins to ensure the x-axis is visible loaded_figure.subplots_adjust(bottom=0.15) # Increase the bottom margin # loaded_figure.tight_layout(pad=2.0) # Use tight layout with padding self.canvas.draw() # Create a new toolbar with the loaded figure self.toolbar = NavigationToolbar(self.canvas, self) self.toolbar.setFixedHeight(50) # Clear the plot layout and re-add toolbar above the canvas self.plot_layout.addWidget(self.toolbar) self.plot_layout.addWidget(self.canvas) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load plot: {e}")
def show_next_plot(self): if self.current_plot_index < len(self.plots) - 1: self.current_plot_index += 1 self.update_plot_canvas() self.prev_button.setEnabled(True) if self.current_plot_index == len(self.plots) - 1: self.next_button.setEnabled(False) else: self.next_button.setEnabled(False) def show_previous_plot(self): if self.current_plot_index > 0: self.current_plot_index -= 1 self.update_plot_canvas() self.next_button.setEnabled(True) if self.current_plot_index == 0: self.prev_button.setEnabled(False) else: self.prev_button.setEnabled(False)
[docs] def cleanup_tempdir(self): """Remove old plot files in temp directory.""" for i in range(1, 3): plot_file = os.path.join(tempfile.gettempdir(), f"plot{i}.pkl") if os.path.exists(plot_file): os.remove(plot_file)
if __name__ == "__main__": app = QApplication(sys.argv) ex = TestRunner() sys.exit(app.exec())