diff --git a/src/frontEnd/Application.py b/src/frontEnd/Application.py index 73c626013..02e27dd0f 100644 --- a/src/frontEnd/Application.py +++ b/src/frontEnd/Application.py @@ -859,6 +859,21 @@ def __init__(self, *args): self.middleSplit.setSizes([self.width(), int(self.height() / 2)]) self.setLayout(self.mainLayout) + def collapse_console_area(self): + """Collapse the console area to minimal height.""" + current_sizes = self.middleSplit.sizes() + total_height = sum(current_sizes) + minimal_console_height = 0 + dock_area_height = total_height - minimal_console_height + self.middleSplit.setSizes([dock_area_height, minimal_console_height]) + + def restore_console_area(self): + """Restore the console area to normal height.""" + total_height = sum(self.middleSplit.sizes()) + dock_area_height = int(total_height * 0.7) # 70% for dock area + console_height = total_height - dock_area_height # 30% for console + self.middleSplit.setSizes([dock_area_height, console_height]) + # It is main function of the module and starts the application def main(args): diff --git a/src/frontEnd/DockArea.py b/src/frontEnd/DockArea.py index d68085f57..e09970f72 100755 --- a/src/frontEnd/DockArea.py +++ b/src/frontEnd/DockArea.py @@ -1,5 +1,5 @@ from PyQt5 import QtCore, QtWidgets -from ngspiceSimulation.pythonPlotting import plotWindow +from ngspiceSimulation import plotWindow from ngspiceSimulation.NgspiceWidget import NgspiceWidget from configuration.Appconfig import Appconfig from modelEditor.ModelEditor import ModelEditorclass @@ -40,6 +40,8 @@ def __init__(self): """This act as constructor for class DockArea.""" QtWidgets.QMainWindow.__init__(self) self.obj_appconfig = Appconfig() + # Track plotting docks + self.active_plotting_docks = set() for dockName in dockList: dock[dockName] = QtWidgets.QDockWidget(dockName) @@ -60,6 +62,27 @@ def __init__(self): # self.tabifyDockWidget(dock['Notes'],dock['Blank']) self.show() + def get_main_view_reference(self): + """Get reference to the MainView widget.""" + parent = self.parent() + while parent: + if hasattr(parent, 'collapse_console_area'): + return parent + parent = parent.parent() + return None + + def on_dock_activated(self, dock_widget): + """Handle when any dock becomes active.""" + main_view = self.get_main_view_reference() + if not main_view: + return + + # Check if activated dock is a plotting dock + if dock_widget in self.active_plotting_docks: + main_view.collapse_console_area() + else: + main_view.restore_console_area() + def createTestEditor(self): """This function create widget for Library Editor""" global count @@ -115,11 +138,25 @@ def plottingEditor(self): dock[dockName + str(count)]) self.tabifyDockWidget(dock['Welcome'], dock[dockName + str(count)]) + + # Track this as a plotting dock + self.active_plotting_docks.add(dock[dockName + str(count)]) + + # Connect to tab change signal + try: + self.tabifiedDockWidgetActivated.connect(self.on_dock_activated) + except: + pass # In case signal is already connected dock[dockName + str(count)].setVisible(True) dock[dockName + str(count)].setFocus() dock[dockName + str(count)].raise_() + # Collapse console immediately + main_view = self.get_main_view_reference() + if main_view: + QtCore.QTimer.singleShot(100, main_view.collapse_console_area) + temp = self.obj_appconfig.current_project['ProjectName'] if temp: self.obj_appconfig.dock_dict[temp].append( diff --git a/src/ngspiceSimulation/NgspiceWidget.py b/src/ngspiceSimulation/NgspiceWidget.py index 6d8a9d742..1562678b3 100644 --- a/src/ngspiceSimulation/NgspiceWidget.py +++ b/src/ngspiceSimulation/NgspiceWidget.py @@ -1,228 +1,418 @@ +""" +NGSpice Widget Module + +This module provides the NgspiceWidget class for running NGSpice simulations +within a PyQt5 application interface. +""" + import os +import logging +from typing import List, Optional from PyQt5 import QtWidgets, QtCore +from PyQt5.QtCore import pyqtSignal, pyqtSlot from configuration.Appconfig import Appconfig from frontEnd import TerminalUi from configparser import ConfigParser -# This Class creates NgSpice Window +# Set up logging +logger = logging.getLogger(__name__) + + class NgspiceWidget(QtWidgets.QWidget): + """ + Widget for running NGSpice simulations with terminal interface. + + This class creates a widget that runs NGSpice processes and displays + their output in a terminal interface. It handles simulation execution, + logging, and provides status feedback through signals. + """ + + # Process error types + ERROR_FAILED_TO_START = 0 + ERROR_CRASHED = 1 + ERROR_TIMED_OUT = 2 + + # Message formatting templates + SUCCESS_FORMAT = ('' + '{}' + '') + FAILURE_FORMAT = ('' + '{}' + '') + + def __init__(self, netlist: str, sim_end_signal: pyqtSignal, plotFlag: Optional[bool] = None) -> None: + """ + Initialize the NgspiceWidget. + + Creates NGSpice simulation window and runs the simulation process. + Handles logging of the NGSpice process, returns simulation status, + and calls the plotter. Also checks if running on Linux and starts GAW. + + Args: + netlist: Path to the .cir.out file containing simulation instructions + sim_end_signal: Signal emitted to Application class for enabling + simulation interaction and plotting data if successful + plotFlag: Whether to show NGSpice plots (True/False) + """ + super().__init__() + + # **CRITICAL FIX**: Set expanding size policy + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) - def __init__(self, netlist, simEndSignal, plotFlag): - """ - - Creates constructor for NgspiceWidget class. - - Creates NgspiceWindow and runs the process - - Calls the logs the ngspice process, returns - it's simulation status and calls the plotter - - Checks whether it is Linux and runs gaw - :param netlist: The file .cir.out file that - contains the instructions. - :type netlist: str - :param simEndSignal: A signal that will be emitted to Application class - for enabling simulation interaction and plotting data if the - simulation is successful - :type simEndSignal: PyQt Signal - """ - QtWidgets.QWidget.__init__(self) + # Set minimum size + self.setMinimumSize(300, 200) + self.obj_appconfig = Appconfig() - self.projDir = self.obj_appconfig.current_project["ProjectName"] - self.args = ['-b', '-r', netlist.replace(".cir.out", ".raw"), netlist] - print("Argument to ngspice: ", self.args) - self.projPath = self.projDir + self.project_dir = self.obj_appconfig.current_project["ProjectName"] + self.netlist_path = netlist + self.sim_end_signal = sim_end_signal + + # **IMPORTANT**: Store plotFlag and command for dual plot functionality + self.plotFlag = plotFlag + self.command = netlist + logger.info(f"Value of plotFlag: {self.plotFlag}") + + # Prepare NGSpice arguments + self.ngspice_args = self._prepare_ngspice_arguments(netlist) + logger.info(f"NGSpice arguments: {self.ngspice_args}") + # Set up the main process self.process = QtCore.QProcess(self) - self.terminalUi = TerminalUi.TerminalUi(self.process, self.args) + self.terminal_ui = TerminalUi.TerminalUi(self.process, self.ngspice_args) + + # Set up layout self.layout = QtWidgets.QVBoxLayout(self) - self.layout.addWidget(self.terminalUi) + self.layout.addWidget(self.terminal_ui) - # Receiving the plotFlag - self.plotFlag = plotFlag - print("Value of plotFlag: ", self.plotFlag) - self.command = netlist + # Configure and start the NGSpice process + self._configure_process() + self._start_process() + + # Start GAW on Linux systems (first instance) + if self._is_linux(): + self._start_gaw_process(netlist) - self.process.setWorkingDirectory(self.projDir) + def _prepare_ngspice_arguments(self, netlist: str) -> List[str]: + """ + Prepare command line arguments for NGSpice. + + Args: + netlist: Path to the netlist file + + Returns: + List of command line arguments for NGSpice + """ + raw_file = netlist.replace(".cir.out", ".raw") + return ['-b', '-r', raw_file, netlist] + + def _configure_process(self) -> None: + """Configure the NGSpice process with working directory and signals.""" + self.process.setWorkingDirectory(self.project_dir) self.process.setProcessChannelMode(QtCore.QProcess.MergedChannels) - self.process.readyRead.connect(self.readyReadAll) + + # Connect process signals + self.process.readyRead.connect(self.ready_read_all) self.process.finished.connect( - lambda exitCode, exitStatus: - self.finishSimulation(exitCode, exitStatus, simEndSignal, False) + lambda exit_code, exit_status: self.finish_simulation( + exit_code, exit_status, self.sim_end_signal, False + ) ) self.process.errorOccurred.connect( - lambda: self.finishSimulation(None, None, simEndSignal, True)) - self.process.start('ngspice', self.args) + lambda: self.finish_simulation(None, None, self.sim_end_signal, True) + ) + def _start_process(self) -> None: + """Start the NGSpice process and register it with the application.""" + self.process.start('ngspice', self.ngspice_args) + + # Register process with application config self.obj_appconfig.process_obj.append(self.process) - print(self.obj_appconfig.proc_dict) - ( - self.obj_appconfig.proc_dict - [self.obj_appconfig.current_project['ProjectName']].append( - self.process.pid()) - ) + logger.debug(f"Process dictionary: {self.obj_appconfig.proc_dict}") + + current_project_name = self.obj_appconfig.current_project['ProjectName'] + if current_project_name in self.obj_appconfig.proc_dict: + self.obj_appconfig.proc_dict[current_project_name].append( + self.process.pid() + ) - if os.name != "nt": # Linux OS - self.gawProcess = QtCore.QProcess(self) - self.gawCommand = "gaw " + netlist.replace(".cir.out", ".raw") - self.gawProcess.start('sh', ['-c', self.gawCommand]) - print(self.gawCommand) + def _is_linux(self) -> bool: + """Check if the current operating system is Linux.""" + return os.name != "nt" - @QtCore.pyqtSlot() - def readyReadAll(self): - """Outputs the ngspice process standard output and standard error - to :class:`TerminalUi.TerminalUi` console + def _start_gaw_process(self, netlist: str) -> None: """ - self.terminalUi.simulationConsole.insertPlainText( - str(self.process.readAllStandardOutput().data(), encoding='utf-8') - ) - - stderror = str(self.process.readAllStandardError().data(), - encoding='utf-8') - - # Suppressing the Ngspice PrinterOnly error that batch mode throws - stderror = '\n'.join([errLine for errLine in stderror.split('\n') - if ('PrinterOnly' not in errLine and - 'viewport for graphics' not in errLine)]) - - self.terminalUi.simulationConsole.insertPlainText(stderror) - - def finishSimulation(self, exitCode, exitStatus, - simEndSignal, hasErrorOccurred): - """This function is intended to run when the Ngspice - simulation finishes. It singals to the function that generates - the plots and also writes in the appropriate status of the - simulation (Whether it was a success or not). - - :param exitCode: The exit code signal of the QProcess - that runs ngspice - :type exitCode: int - :param exitStatus: The exit status signal of the - qprocess that runs ngspice - :type exitStatus: class:`QtCore.QProcess.ExitStatus` - :param simEndSignal: A signal passed from constructor - for enabling simulation interaction and plotting data if the - simulation is successful - :type simEndSignal: PyQt Signal - """ - - # Canceling simulation triggers both finished and - # errorOccurred signals...need to skip finished signal in this case. - if not hasErrorOccurred and self.terminalUi.simulationCancelled: + Start GAW (GTK Analog Waveform viewer) process on Linux. + + Args: + netlist: Path to the netlist file + """ + try: + self.gaw_process = QtCore.QProcess(self) + raw_file = netlist.replace(".cir.out", ".raw") + self.gaw_command = f"gaw {raw_file}" + self.gaw_process.start('sh', ['-c', self.gaw_command]) + logger.info(f"Started GAW with command: {self.gaw_command}") + except Exception as e: + logger.error(f"Failed to start GAW process: {e}") + + @pyqtSlot() + def ready_read_all(self) -> None: + """ + Handle process output and display it in the terminal console. + + Reads both standard output and standard error from the NGSpice process + and displays them in the TerminalUi console. Filters out specific + NGSpice warnings that are not relevant in batch mode. + """ + try: + # Read and display standard output + std_output = self.process.readAllStandardOutput().data() + if std_output: + output_text = str(std_output, encoding='utf-8') + self.terminal_ui.simulationConsole.insertPlainText(output_text) + + # Read and filter standard error + std_error = self.process.readAllStandardError().data() + if std_error: + error_text = str(std_error, encoding='utf-8') + + # Filter out irrelevant NGSpice warnings in batch mode + filtered_lines = [] + for line in error_text.split('\n'): + if ('PrinterOnly' not in line and + 'viewport for graphics' not in line): + filtered_lines.append(line) + + filtered_error = '\n'.join(filtered_lines) + if filtered_error.strip(): + self.terminal_ui.simulationConsole.insertPlainText(filtered_error) + + except Exception as e: + logger.error(f"Error reading process output: {e}") + + def finish_simulation(self, exit_code: Optional[int], + exit_status: Optional[QtCore.QProcess.ExitStatus], + sim_end_signal: pyqtSignal, + has_error_occurred: bool) -> None: + """ + Handle simulation completion and update UI accordingly. + + This method is called when the NGSpice simulation finishes. It updates + the UI state, displays appropriate status messages, and emits signals + for plot generation if the simulation was successful. + + Args: + exit_code: Process exit code + exit_status: Process exit status + sim_end_signal: Signal to emit when simulation ends + has_error_occurred: Whether an error occurred during simulation + """ + # Skip finished signal if cancellation triggered both finished and error signals + if not has_error_occurred and self.terminal_ui.simulationCancelled: return - # Stop progressbar from running after simulation is completed - self.terminalUi.progressBar.setMaximum(100) - self.terminalUi.progressBar.setProperty("value", 100) - self.terminalUi.cancelSimulationButton.setEnabled(False) - self.terminalUi.redoSimulationButton.setEnabled(True) - - if exitCode is None: - exitCode = self.process.exitCode() - - errorType = self.process.error() - if errorType < 3: # 0, 1, 2 ==> failed to start, crashed, timedout - exitStatus = QtCore.QProcess.CrashExit - elif exitStatus is None: - exitStatus = self.process.exitStatus() - - if self.terminalUi.simulationCancelled: - msg = QtWidgets.QMessageBox() - msg.setModal(True) - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setWindowTitle("Warning Message") - msg.setText("Simulation was cancelled.") - msg.setStandardButtons(QtWidgets.QMessageBox.Ok) - msg.exec() - - elif exitStatus == QtCore.QProcess.NormalExit and exitCode == 0 \ - and errorType == QtCore.QProcess.UnknownError: - # Redo-simulation does not set correct exit status and code. - # So, need to check the error type ==> - # UnknownError along with NormalExit seems successful simulation - - successFormat = '\ - {} \ - ' - self.terminalUi.simulationConsole.append( - successFormat.format("Simulation Completed Successfully!")) - + # Update UI state after simulation completion + self._update_ui_after_simulation() + + # Get actual exit code and status if not provided + if exit_code is None: + exit_code = self.process.exitCode() + + error_type = self.process.error() + if error_type <= self.ERROR_TIMED_OUT: # FailedToStart, Crashed, TimedOut + exit_status = QtCore.QProcess.CrashExit + elif exit_status is None: + exit_status = self.process.exitStatus() + + # Handle different simulation outcomes + if self.terminal_ui.simulationCancelled: + self._show_cancellation_message() + elif self._is_simulation_successful(exit_status, exit_code, error_type): + self._show_success_message() + + # **CRITICAL ADDITION**: Check and update plotFlag from process properties + # This handles the re-simulation case from TerminalUi + new_plot_flag = self.process.property("plotFlag") + if new_plot_flag is not None: + self.plotFlag = new_plot_flag + + new_plot_flag2 = self.process.property("plotFlag2") + if new_plot_flag2 is not None: + self.plotFlag = new_plot_flag2 + + # **CRITICAL ADDITION**: Open NGSpice plot windows if requested + if self.plotFlag: + self.open_ngspice_plots() else: - failedFormat = ' \ - {} \ - ' - self.terminalUi.simulationConsole.append( - failedFormat.format("Simulation Failed!")) - - errMsg = 'Simulation ' - if errorType == QtCore.QProcess.FailedToStart: - errMsg += 'failed to start. ' + \ - 'Ensure that eSim is installed correctly.' - elif errorType == QtCore.QProcess.Crashed: - errMsg += 'crashed. Try again later.' - elif errorType == QtCore.QProcess.Timedout: - errMsg += ' has timed out. Try to reduce the ' + \ - ' simulation time or the simulation step interval.' - else: - errMsg += ' could not complete. Try again later.' - - msg = QtWidgets.QErrorMessage() - msg.setModal(True) - msg.setWindowTitle("Error Message") - msg.showMessage(errMsg) - msg.exec() - - self.terminalUi.simulationConsole.verticalScrollBar().setValue( - self.terminalUi.simulationConsole.verticalScrollBar().maximum() - ) - - """ Get the plotFlag from process if it exists, otherwise use current plotFlag, plotFlag2 is for pop which appears when resimulate is clicked on ngSpice window """ - newPlotFlag = self.process.property("plotFlag") - if newPlotFlag is not None: - self.plotFlag = newPlotFlag - - newPlotFlag2 = self.process.property("plotFlag2") - if newPlotFlag2 is not None: - self.plotFlag = newPlotFlag2 + self._show_failure_message(error_type) - if self.plotFlag: - self.plotFlagFunc(self.projPath, self.command) + # Scroll terminal to bottom + self._scroll_terminal_to_bottom() - simEndSignal.emit(exitStatus, exitCode) + # Emit completion signal + sim_end_signal.emit(exit_status, exit_code) - def plotFlagFunc(self,projPath,command): - if self.plotFlag == True: - if os.name == 'nt': + def open_ngspice_plots(self) -> None: + """ + Open NGSpice plotting windows (native NGSpice plots). + This function handles both Windows and Linux platforms. + """ + logger.info("Opening NGSpice native plots") + + if os.name == 'nt': # Windows + try: parser_nghdl = ConfigParser() config_path = os.path.join('library', 'config', '.nghdl', 'config.ini') parser_nghdl.read(config_path) msys_home = parser_nghdl.get('COMPILER', 'MSYS_HOME') - tempdir = os.getcwd() - projPath = self.obj_appconfig.current_project["ProjectName"] - os.chdir(projPath) - self.command = ( - 'cmd /c "start /min ' + - msys_home + '/usr/bin/mintty.exe ngspice -p ' + command + '"' + temp_dir = os.getcwd() + os.chdir(self.project_dir) + + # Create command for Windows using mintty + mintty_command = ( + f'cmd /c "start /min {msys_home}/usr/bin/mintty.exe ' + f'ngspice -p {self.command}"' ) - + # Create a new QProcess for mintty - self.minttyProcess = QtCore.QProcess(self) - self.minttyProcess.start(self.command) - - os.chdir(tempdir) - else: - self.commandi = "cd " + projPath + \ - ";ngspice -r " + command.replace(".cir.out", ".raw") + \ - " " + command - self.xtermArgs = ['-hold', '-e', self.commandi] - - self.xtermProcess = QtCore.QProcess(self) - self.xtermProcess.start('xterm', self.xtermArgs) - - self.obj_appconfig.process_obj.append(self.xtermProcess) - print(self.obj_appconfig.proc_dict) - ( - self.obj_appconfig.proc_dict - [self.obj_appconfig.current_project['ProjectName']].append( - self.xtermProcess.pid()) + self.mintty_process = QtCore.QProcess(self) + self.mintty_process.start(mintty_command) + + os.chdir(temp_dir) + logger.info(f"Started mintty with command: {mintty_command}") + + except Exception as e: + logger.error(f"Failed to start Windows NGSpice plots: {e}") + + else: # Linux/Unix + try: + # Create xterm command for interactive NGSpice + xterm_command = ( + f"cd {self.project_dir}; " + f"ngspice -r {self.command.replace('.cir.out', '.raw')} " + f"{self.command}" ) + xterm_args = ['-hold', '-e', xterm_command] + + # Create new QProcess for xterm + self.xterm_process = QtCore.QProcess(self) + self.xterm_process.start('xterm', xterm_args) + + # Register the process + self.obj_appconfig.process_obj.append(self.xterm_process) + current_project = self.obj_appconfig.current_project['ProjectName'] + if current_project in self.obj_appconfig.proc_dict: + self.obj_appconfig.proc_dict[current_project].append( + self.xterm_process.pid() + ) + + # Also restart GAW for the new plot window + if hasattr(self, 'gaw_process') and hasattr(self, 'gaw_command'): + self.gaw_process.start('sh', ['-c', self.gaw_command]) + logger.info(f"Restarted GAW: {self.gaw_command}") + + logger.info(f"Started xterm with args: {xterm_args}") + + except Exception as e: + logger.error(f"Failed to start Linux NGSpice plots: {e}") + + def _update_ui_after_simulation(self) -> None: + """Update UI elements after simulation completion.""" + self.terminal_ui.progressBar.setMaximum(100) + self.terminal_ui.progressBar.setProperty("value", 100) + self.terminal_ui.cancelSimulationButton.setEnabled(False) + self.terminal_ui.redoSimulationButton.setEnabled(True) + + def _is_simulation_successful(self, exit_status: QtCore.QProcess.ExitStatus, + exit_code: int, + error_type: QtCore.QProcess.ProcessError) -> bool: + """ + Determine if the simulation completed successfully. + + Args: + exit_status: Process exit status + exit_code: Process exit code + error_type: Process error type + + Returns: + True if simulation was successful, False otherwise + """ + return (exit_status == QtCore.QProcess.NormalExit and + exit_code == 0 and + error_type == QtCore.QProcess.UnknownError) + + def _show_cancellation_message(self) -> None: + """Display simulation cancellation message.""" + message_dialog = QtWidgets.QMessageBox() + message_dialog.setModal(True) + message_dialog.setIcon(QtWidgets.QMessageBox.Warning) + message_dialog.setWindowTitle("Warning Message") + message_dialog.setText("Simulation was cancelled.") + message_dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) + message_dialog.exec() + + def _show_success_message(self) -> None: + """Display simulation success message in the terminal.""" + success_message = self.SUCCESS_FORMAT.format("Simulation Completed Successfully!") + self.terminal_ui.simulationConsole.append(success_message) + + def _show_failure_message(self, error_type: QtCore.QProcess.ProcessError) -> None: + """ + Display simulation failure message. + + Args: + error_type: Type of process error that occurred + """ + # Display failure message in terminal + failure_message = self.FAILURE_FORMAT.format("Simulation Failed!") + self.terminal_ui.simulationConsole.append(failure_message) + + # Determine specific error message + error_message = self._get_error_message(error_type) + + # Show error dialog + error_dialog = QtWidgets.QErrorMessage() + error_dialog.setModal(True) + error_dialog.setWindowTitle("Error Message") + error_dialog.showMessage(error_message) + error_dialog.exec() + + def _get_error_message(self, error_type: QtCore.QProcess.ProcessError) -> str: + """ + Get appropriate error message based on error type. + + Args: + error_type: Type of process error + + Returns: + Human-readable error message + """ + error_messages = { + QtCore.QProcess.FailedToStart: ( + 'Simulation failed to start. ' + 'Ensure that eSim is installed correctly.' + ), + QtCore.QProcess.Crashed: ( + 'Simulation crashed. Try again later.' + ), + QtCore.QProcess.Timedout: ( + 'Simulation has timed out. Try to reduce the ' + 'simulation time or the simulation step interval.' + ) + } + + return error_messages.get( + error_type, + 'Simulation could not complete. Try again later.' + ) + + def _scroll_terminal_to_bottom(self) -> None: + """Scroll the terminal console to the bottom.""" + scrollbar = self.terminal_ui.simulationConsole.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) - self.gawProcess.start('sh', ['-c', self.gawCommand]) - print("last:", self.gawCommand) + def sizeHint(self) -> QtCore.QSize: + """Provide proper size hint.""" + return QtCore.QSize(800, 600) diff --git a/src/ngspiceSimulation/__init__.py b/src/ngspiceSimulation/__init__.py index e69de29bb..31e9339b4 100644 --- a/src/ngspiceSimulation/__init__.py +++ b/src/ngspiceSimulation/__init__.py @@ -0,0 +1,14 @@ +# ngspiceSimulation/__init__.py +""" +NGSpice Simulation Module + +This package provides NGSpice simulation integration including: +- NgspiceWidget: Widget for running NGSpice simulations +- plotWindow: Window for plotting and analyzing simulation results +""" + +from .NgspiceWidget import NgspiceWidget +from .plot_window import plotWindow + +__all__ = ['NgspiceWidget', 'plotWindow'] +__version__ = '1.0.0' diff --git a/src/ngspiceSimulation/data_extraction.py b/src/ngspiceSimulation/data_extraction.py new file mode 100644 index 000000000..d628e49c7 --- /dev/null +++ b/src/ngspiceSimulation/data_extraction.py @@ -0,0 +1,322 @@ +# ngspiceSimulation/data_extraction.py +""" +Data extraction module for NGSpice simulation results. + +This module handles the extraction and processing of simulation data from NGSpice +output files, supporting AC, DC, and Transient analysis types. +""" + +import os +import logging +from decimal import Decimal +from typing import List, Tuple, Dict, Any, Optional +from PyQt5 import QtWidgets +from configuration.Appconfig import Appconfig + +# Set up logging +logger = logging.getLogger(__name__) + + +class DataExtraction: + """ + Extracts and processes simulation data from NGSpice output files. + + This class handles reading and parsing voltage and current data from + NGSpice simulation output files for different analysis types. + """ + + # Analysis type constants + AC_ANALYSIS = 0 + TRANSIENT_ANALYSIS = 1 + DC_ANALYSIS = 2 + + def __init__(self) -> None: + """Initialize the DataExtraction instance.""" + self.obj_appconfig = Appconfig() + self.data: List[str] = [] + # consists of all the columns of data belonging to nodes and branches + self.y: List[List[Decimal]] = [] # stores y-axis data + self.x: List[Decimal] = [] # stores x-axis data + # Add the missing instance variables + self.NBList: List[str] = [] + self.NBIList: List[str] = [] + self.volts_length: int = 0 + + def numberFinder(self, file_path: str) -> List[int]: + """ + Analyze simulation files to determine data structure parameters. + + Args: + file_path: Path to the directory containing simulation files + + Returns: + List containing [lines_per_node, voltage_nodes, analysis_type, + dec_flag, current_branches] + """ + # Opening Analysis file + with open(os.path.join(file_path, "analysis")) as analysis_file: + self.analysisInfo = analysis_file.read() + self.analysisInfo = self.analysisInfo.split(" ") + + # Reading data file for voltage + with open(os.path.join(file_path, "plot_data_v.txt")) as voltage_file: + self.voltData = voltage_file.read() + + self.voltData = self.voltData.split("\n") + + # Initializing variable + # 'lines_per_node' gives no. of lines of data for each node/branch + # 'partitions_per_voltage_node' gives the no of partitions for a single voltage node + # 'voltage_node_count' gives total number of voltage + # 'current_branch_count' gives total number of current + + lines_per_node = partitions_per_voltage_node = voltage_node_count = current_branch_count = 0 + + # Finding total number of voltage node + for line in self.voltData[3:]: + # it has possible names of voltage nodes in NgSpice + if "Index" in line: # "V(" in line or "x1" in line or "u3" in line: + voltage_node_count += 1 + + # Reading Current Source Data + with open(os.path.join(file_path, "plot_data_i.txt")) as current_file: + self.currentData = current_file.read() + self.currentData = self.currentData.split("\n") + + # Finding Number of Branch + for line in self.currentData[3:]: + if "#branch" in line: + current_branch_count += 1 + + self.dec = 0 + + # For AC + if self.analysisInfo[0][-3:] == ".ac": + self.analysisType = self.AC_ANALYSIS + if "dec" in self.analysisInfo: + self.dec = 1 + + for line in self.voltData[3:]: + lines_per_node += 1 # 'lines_per_node' gives no. of lines of data for each node/branch + if "Index" in line: + partitions_per_voltage_node += 1 + # 'partitions_per_voltage_node' gives the no of partitions for a single voltage node + logger.debug(f"partitions_per_voltage_node: {partitions_per_voltage_node}") + if "AC" in line: # DC for dc files and AC for ac ones + break + + elif ".tran" in self.analysisInfo: + self.analysisType = self.TRANSIENT_ANALYSIS + for line in self.voltData[3:]: + lines_per_node += 1 + if "Index" in line: + partitions_per_voltage_node += 1 + # 'partitions_per_voltage_node' gives the no of partitions for a single voltage node + logger.debug(f"partitions_per_voltage_node: {partitions_per_voltage_node}") + if "Transient" in line: # DC for dc files and AC for ac ones + break + + # For DC: + else: + self.analysisType = self.DC_ANALYSIS + for line in self.voltData[3:]: + lines_per_node += 1 + if "Index" in line: + partitions_per_voltage_node += 1 + # 'partitions_per_voltage_node' gives the no of partitions for a single voltage node + logger.debug(f"partitions_per_voltage_node: {partitions_per_voltage_node}") + if "DC" in line: # DC for dc files and AC for ac ones + break + + voltage_node_count = voltage_node_count // partitions_per_voltage_node # voltage_node_count gives the no of voltage nodes + current_branch_count = current_branch_count // partitions_per_voltage_node # current_branch_count gives the no of branches + + analysis_params = [lines_per_node, voltage_node_count, self.analysisType, self.dec, current_branch_count] + + return analysis_params + + def openFile(self, file_path: str) -> List[int]: + """ + Open and process simulation data files. + + Args: + file_path: Path to the directory containing simulation files + + Returns: + List containing [analysis_type, dec_flag] + + Raises: + Exception: If files cannot be read or processed + """ + try: + with open(os.path.join(file_path, "plot_data_i.txt")) as current_file: + all_current_data = current_file.read() + + all_current_data = all_current_data.split("\n") + self.NBIList = [] + + with open(os.path.join(file_path, "plot_data_v.txt")) as voltage_file: + all_voltage_data = voltage_file.read() + + except Exception as e: + logger.error(f"Exception reading files: {e}") + self.obj_appconfig.print_error(f'Exception Message: {e}') + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage('Unable to open plot data files.') + self.msg.exec_() + + try: + try: + for token in all_current_data[3].split(" "): + if len(token) > 0: + self.NBIList.append(token) + self.NBIList = self.NBIList[2:] + current_list_length = len(self.NBIList) + except (IndexError, AttributeError) as e: + logger.warning(f"Error parsing current data: {e}") + self.NBIList = [] + current_list_length = 0 + except Exception as e: + logger.error(f"Exception parsing current data: {e}") + self.obj_appconfig.print_error(f'Exception Message: {e}') + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage('Unable to read Analysis File.') + self.msg.exec_() + + data_params = self.numberFinder(file_path) + lines_per_partition = int(data_params[0] + 1) + voltage_node_count = int(data_params[1]) + analysis_type = data_params[2] + current_branch_count = data_params[4] + + analysis_info = [analysis_type, data_params[3]] + self.NBList = [] + all_voltage_data = all_voltage_data.split("\n") + for token in all_voltage_data[3].split(" "): + if len(token) > 0: + self.NBList.append(token) + self.NBList = self.NBList[2:] + voltage_list_length = len(self.NBList) + logger.info(f"NBLIST: {self.NBList}") + + processed_current_data = [] + voltage_column_count = len(all_voltage_data[5].split("\t")) + current_column_count = len(all_current_data[5].split("\t")) + + full_data = [] + + # Creating list of data: + if analysis_type < 3: + for voltage_node_index in range(1, voltage_node_count): + for token in all_voltage_data[3 + voltage_node_index * lines_per_partition].split(" "): + if len(token) > 0: + self.NBList.append(token) + self.NBList.pop(voltage_list_length) + self.NBList.pop(voltage_list_length) + voltage_list_length = len(self.NBList) + + for current_branch_index in range(1, current_branch_count): + for token in all_current_data[3 + current_branch_index * lines_per_partition].split(" "): + if len(token) > 0: + self.NBIList.append(token) + self.NBIList.pop(current_list_length) + self.NBIList.pop(current_list_length) + current_list_length = len(self.NBIList) + + partition_row_index = 0 + data_row_index = 0 + combined_row_index = 0 + + for line in all_current_data[5:lines_per_partition - 1]: + if len(line.split("\t")) == current_column_count: + current_row = line.split("\t") + current_row.pop(0) + current_row.pop(0) + current_row.pop() + if analysis_type == 0: # not in trans + current_row.pop() + + for current_partition_index in range(1, current_branch_count): + additional_current_line = all_current_data[5 + current_partition_index * lines_per_partition + data_row_index].split("\t") + additional_current_line.pop(0) + additional_current_line.pop(0) + if analysis_type == 0: + additional_current_line.pop() # not required for dc + additional_current_line.pop() + current_row = current_row + additional_current_line + + full_data.append(current_row) + + data_row_index += 1 + + for line in all_voltage_data[5:lines_per_partition - 1]: + if len(line.split("\t")) == voltage_column_count: + voltage_row = line.split("\t") + voltage_row.pop() + if analysis_type == 0: + voltage_row.pop() + for voltage_partition_index in range(1, voltage_node_count): + additional_voltage_line = all_voltage_data[5 + voltage_partition_index * lines_per_partition + partition_row_index].split("\t") + additional_voltage_line.pop(0) + additional_voltage_line.pop(0) + if analysis_type == 0: + additional_voltage_line.pop() # not required for dc + if self.NBList[len(self.NBList) - 1] == 'v-sweep': + self.NBList.pop() + additional_voltage_line.pop() + + additional_voltage_line.pop() + voltage_row = voltage_row + additional_voltage_line + voltage_row = voltage_row + full_data[combined_row_index] + combined_row_index += 1 + + combined_row_str = "\t".join(voltage_row[1:]) + combined_row_str = combined_row_str.replace(",", "") + self.data.append(combined_row_str) + + partition_row_index += 1 + + self.volts_length = len(self.NBList) + self.NBList = self.NBList + self.NBIList + + logger.info(f"Analysis info: {analysis_info}") + return analysis_info + + def numVals(self) -> List[int]: + """ + Get the number of data columns and voltage nodes. + + Returns: + List containing [total_columns, voltage_node_count] + """ + total_columns = len(self.data[0].split("\t")) + voltage_node_count = self.volts_length + return [total_columns, voltage_node_count] + + def computeAxes(self) -> None: + """ + Compute x and y axis data from the processed simulation data. + + This method extracts the time/frequency data (x-axis) and + voltage/current data (y-axis) from the processed data. + """ + if not self.data: + logger.warning("No data available for axis computation") + return + + num_columns = len(self.data[0].split("\t")) + self.y = [] + first_row_values = self.data[0].split("\t") + for column_index in range(1, num_columns): + self.y.append([Decimal(first_row_values[column_index])]) + for row in self.data[1:]: + row_values = row.split("\t") + for column_index in range(1, num_columns): + self.y[column_index - 1].append(Decimal(row_values[column_index])) + for row in self.data: + row_values = row.split("\t") + self.x.append(Decimal(row_values[0])) diff --git a/src/ngspiceSimulation/plot_window.py b/src/ngspiceSimulation/plot_window.py new file mode 100644 index 000000000..dc22c2459 --- /dev/null +++ b/src/ngspiceSimulation/plot_window.py @@ -0,0 +1,1271 @@ +# ngspiceSimulation/plot_window.py +""" +Plot Window Module + +This module provides the main plotting window for NGSpice simulation results +with support for AC, DC, and Transient analysis visualization. +""" + +from __future__ import division +import os +import sys +import json +import traceback +import logging +from pathlib import Path +from decimal import Decimal, getcontext +from typing import Dict, List, Optional, Tuple, Any, Union + +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtCore import Qt, QSettings, pyqtSignal +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, + QCheckBox, QRadioButton, QButtonGroup, QGroupBox, + QLabel, QLineEdit, QSlider, QDoubleSpinBox, QMenu, + QAction, QFileDialog, QColorDialog, QInputDialog, + QMessageBox, QErrorMessage, QStatusBar, QStyle, + QSplitter, QToolButton, QWidgetAction, QGridLayout, + QSpacerItem, QSizePolicy,QScrollArea) +from PyQt5.QtGui import (QColor, QBrush, QPalette, QKeySequence, + QPainter, QPixmap, QFont) + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.backend_bases import NavigationToolbar2 +from matplotlib.figure import Figure +from matplotlib.widgets import Cursor +from matplotlib.lines import Line2D +from matplotlib.text import Text + +from configuration.Appconfig import Appconfig +from .plotting_widgets import CollapsibleBox, MultimeterWidgetClass +from .data_extraction import DataExtraction + +# Set up logging +logger = logging.getLogger(__name__) + +# Constants +DEFAULT_WINDOW_WIDTH = 1400 +DEFAULT_WINDOW_HEIGHT = 800 +DEFAULT_DPI = 100 +DEFAULT_FIGURE_SIZE = (10, 8) +DEFAULT_LINE_THICKNESS = 1.5 +DEFAULT_VERTICAL_SPACING = 1.2 # <-- UI Change: Reverted to original value +DEFAULT_ZOOM_FACTOR = 0.9 +CURSOR_ALPHA = 0.7 +THRESHOLD_ALPHA = 0.5 +LEGEND_FONT_SIZE = 9 +DEFAULT_EXPORT_DPI = 300 + +# Color Constants +VIBRANT_COLOR_PALETTE = [ + '#E53935', # Vivid Red + '#1E88E5', # Strong Blue + '#43A047', # Rich Green + '#FB8C00', # Bright Orange + '#8E24AA', # Deep Purple + '#00ACC1', # Vibrant Teal + '#D81B60', # Strong Pink + '#6D4C41', # Earthy Brown + '#FDD835', # Visible Amber + '#039BE5', # Sky Blue + '#C0CA33', # Lime Green + '#37474F' # Dark Grey +] + +# Time unit conversion thresholds (more precise) +TIME_UNIT_THRESHOLD_PS = 1e-9 +TIME_UNIT_THRESHOLD_NS = 1e-6 +TIME_UNIT_THRESHOLD_US = 1e-3 +TIME_UNIT_THRESHOLD_MS = 1 + +# Line style options +LINE_STYLES = [ + ('-', "Solid"), + ('--', "Dashed"), + (':', "Dotted"), + ('steps-post', "Step (Post)") +] + +# Thickness options +THICKNESS_OPTIONS = [ + (1.0, "1 px"), + (1.5, "1.5 px"), + (2.0, "2 px"), + (3.0, "3 px") +] + + +class Trace: + """Single class to manage all trace properties.""" + + def __init__(self, index: int, name: str, color: str = None, + thickness: float = DEFAULT_LINE_THICKNESS, style: str = '-', + visible: bool = False) -> None: + self.index = index + self.name = name + self.color = color or VIBRANT_COLOR_PALETTE[0] + self.thickness = thickness + self.style = style + self.visible = visible + self.line_object: Optional[Line2D] = None + + def update_line(self, **kwargs) -> None: + if self.line_object: + if 'color' in kwargs: + self.color = kwargs['color'] + self.line_object.set_color(self.color) + if 'thickness' in kwargs: + self.thickness = kwargs['thickness'] + self.line_object.set_linewidth(self.thickness) + if 'style' in kwargs: + self.style = kwargs['style'] + if self.style != 'steps-post': + self.line_object.set_linestyle(self.style) + + +class CustomListWidget(QListWidget): + """Custom QListWidget that handles selection without default styling.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setSelectionMode(QListWidget.MultiSelection) + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + super().paintEvent(event) + + +class plotWindow(QWidget): + """Main plotting widget for NGSpice simulation results.""" + + def __init__(self, file_path: str, project_name: str, parent=None) -> None: + super().__init__(parent) + + self.file_path = file_path + self.project_name = project_name + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + self.setMinimumSize(400, 300) + self.obj_appconfig = Appconfig() + logger.info(f"Complete Project Path: {self.file_path}") + logger.info(f"Project Name: {self.project_name}") + self.obj_appconfig.print_info(f'NGSpice simulation called: {self.file_path}') + self.obj_appconfig.print_info(f'PythonPlotting called: {self.file_path}') + + self._initialize_data_structures() + self._initialize_configuration() + self.create_main_frame() + self.load_simulation_data() + self.apply_theme() + + def _initialize_data_structures(self) -> None: + self.active_traces: Dict[int, Line2D] = {} + self.trace_visibility: Dict[int, bool] = {} + self.trace_colors: Dict[int, str] = {} + self.trace_thickness: Dict[int, float] = {} + self.trace_style: Dict[int, str] = {} + self.trace_names: Dict[int, str] = {} + self.cursor_lines: List[Optional[Line2D]] = [] + self.cursor_positions: List[Optional[float]] = [] + self.timing_annotations: Dict[int, Any] = {} + self.color_palette = VIBRANT_COLOR_PALETTE.copy() + self.color: List[str] = [] + self.color_index = 0 + self.logic_threshold: Optional[float] = None + self.vertical_spacing = DEFAULT_VERTICAL_SPACING + + def _initialize_configuration(self) -> None: + self.config_dir = Path.home() / '.pythonPlotting' + self.config_file = self.config_dir / 'config.json' + self.config: Dict[str, Any] = self.load_config() + self.settings = QSettings('eSim', 'PythonPlotting') + + def load_config(self) -> Dict[str, Any]: + try: + self.config_dir.mkdir(exist_ok=True) + if self.config_file.exists(): + with open(self.config_file, 'r', encoding='utf-8') as config_file: + config = json.load(config_file) + if 'theme' in config: + del config['theme'] + return config + except Exception as e: + logger.error(f"Error loading config: {e}") + return {'trace_colours': {}, 'trace_thickness': {}, 'trace_style': {}, 'experimental_acdc': False} + + def save_config(self) -> None: + try: + self.config_dir.mkdir(exist_ok=True) + self.config['trace_colours'] = {self.trace_names.get(idx, self.obj_dataext.NBList[idx]): color for idx, color in self.trace_colors.items()} + self.config['trace_thickness'] = {self.trace_names.get(idx, self.obj_dataext.NBList[idx]): thickness for idx, thickness in self.trace_thickness.items()} + self.config['trace_style'] = {self.trace_names.get(idx, self.obj_dataext.NBList[idx]): style for idx, style in self.trace_style.items()} + temp_file = self.config_file.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as config_file: + json.dump(self.config, config_file, indent=2) + temp_file.replace(self.config_file) + except Exception as e: + logger.error(f"Error saving config: {e}") + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.save_config() + if hasattr(self, 'canvas'): + self.canvas.close() + if hasattr(self, 'fig'): + plt.close(self.fig) + super().closeEvent(event) + + def apply_theme(self) -> None: + theme_stylesheet = """ + QMenuBar { border-radius: 8px; background-color: #FFFFFF; border: 1px solid #E0E0E0; padding: 2px; } + QStatusBar { border-radius: 8px; background-color: #FFFFFF; border: 1px solid #E0E0E0; padding: 2px; } + QWidget { background-color: #FFFFFF; color: #212121; } + QListWidget { background-color: #FFFFFF; border: 1px solid #E0E0E0; padding: 2px; outline: none; selection-background-color: transparent; selection-color: inherit; } + QListWidget::item { min-height: 32px; padding: 6px 8px; margin: 2px 4px; background-color: transparent; border: none; } + QListWidget::item:selected { background-color: transparent; border: none; } + QListWidget::item:hover { background-color: rgba(0, 0, 0, 0.04); } + QListWidget::item:focus { outline: none; } + QGroupBox { border: 1px solid #E0E0E0; margin-top: 0.5em; padding-top: 0.5em; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; } + QPushButton { background-color: #FFFFFF; border: 1px solid #E0E0E0; padding: 6px 12px; font-weight: 500; } + QPushButton:hover { background-color: #F2F2F2; border-color: #1976D2; } + QPushButton:pressed { background-color: #E0E0E0; } + QCheckBox::indicator { width: 16px; height: 16px; } + QMenu { background-color: #FFFFFF; border: 1px solid #E0E0E0; } + QMenu::item:selected { background-color: #E3F2FD; } + QLineEdit { border: 1px solid #E0E0E0; padding: 6px 12px; background-color: #FAFAFA; } + QLineEdit:focus { border-color: #1976D2; background-color: #FFFFFF; } + QSlider::groove:horizontal { border: 1px solid #E0E0E0; height: 4px; background: #E0E0E0; } + QSlider::handle:horizontal { background: #1976D2; border: 1px solid #1976D2; width: 16px; height: 16px; margin: -6px 0; } + QScrollBar:vertical { background-color: #F5F5F5; width: 8px; border: none; border-radius: 4px; } + QScrollBar::handle:vertical { background-color: #BDBDBD; border-radius: 4px; min-height: 20px; margin: 2px; } + QScrollBar::handle:vertical:hover { background-color: #9E9E9E; } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } + """ + self.setStyleSheet(theme_stylesheet) + + def create_main_frame(self) -> None: + main_widget_layout = QVBoxLayout(self) + main_widget_layout.setContentsMargins(5, 5, 5, 5) + self.menu_bar = QtWidgets.QMenuBar(self) + main_widget_layout.addWidget(self.menu_bar) + content_widget = QWidget() + content_widget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + main_layout = QHBoxLayout(content_widget) + self.splitter = QSplitter(Qt.Horizontal) + self.splitter.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + left_widget = self.create_waveform_list() + self.splitter.addWidget(left_widget) + center_widget = self.create_plot_area() + self.splitter.addWidget(center_widget) + right_widget = self.create_control_panel() + scroll_area = QScrollArea() + scroll_area.setWidget(right_widget) + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QtWidgets.QFrame.NoFrame) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scrollbar_style = "QScrollBar:vertical{background-color:#F5F5F5;width:8px;border:none;border-radius:4px;}QScrollBar::handle:vertical{background-color:#BDBDBD;border-radius:4px;min-height:20px;margin:2px;}QScrollBar::handle:vertical:hover{background-color:#9E9E9E;}QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0px;}QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{background:transparent;}" + scroll_area.verticalScrollBar().setStyleSheet(scrollbar_style) + self.splitter.addWidget(scroll_area) + self.splitter.setSizes([280, 840, 280]) + main_layout.addWidget(self.splitter) + main_widget_layout.addWidget(content_widget) + self.status_bar = QStatusBar() + self.coord_label = QLabel("X: --, Y: --") + self.status_bar.addWidget(self.coord_label) + self.measure_label = QLabel("") + self.status_bar.addPermanentWidget(self.measure_label) + main_widget_layout.addWidget(self.status_bar) + self.create_menu_bar() + self.setWindowTitle(f'Python Plotting - {self.project_name}') + + def create_waveform_list(self) -> QWidget: + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + self.analysis_label = QLabel() + self.analysis_label.setStyleSheet("font-weight: bold; font-size: 14px; padding: 5px;") + left_layout.addWidget(self.analysis_label) + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Search waveforms...") + self.search_box.textChanged.connect(self.filter_waveforms) + left_layout.addWidget(self.search_box) + self.waveform_list = CustomListWidget() + self.waveform_list.itemClicked.connect(self.on_waveform_toggle) + self.waveform_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.waveform_list.customContextMenuRequested.connect(self.show_list_context_menu) + self.waveform_list.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.waveform_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + left_layout.addWidget(self.waveform_list) + button_layout = QHBoxLayout() + self.select_all_btn = QPushButton("Select All") + self.select_all_btn.clicked.connect(self.select_all_waveforms) + self.deselect_all_btn = QPushButton("Deselect All") + self.deselect_all_btn.clicked.connect(self.deselect_all_waveforms) + button_layout.addWidget(self.select_all_btn) + button_layout.addWidget(self.deselect_all_btn) + left_layout.addLayout(button_layout) + return left_widget + + def create_plot_area(self) -> QWidget: + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + self.fig = Figure(figsize=DEFAULT_FIGURE_SIZE, dpi=DEFAULT_DPI) + self.canvas = FigureCanvas(self.fig) + self.nav_toolbar = NavigationToolbar(self.canvas, self) + self.nav_toolbar.addSeparator() + fig_options_action = QAction('⚙', self.nav_toolbar) + fig_options_action.triggered.connect(self.open_figure_options) + fig_options_action.setToolTip('Figure Options (P)') + self.nav_toolbar.addAction(fig_options_action) + center_layout.addWidget(self.nav_toolbar) + center_layout.addWidget(self.canvas) + self.canvas.mpl_connect('button_press_event', self.on_canvas_click) + self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move) + self.canvas.mpl_connect('key_press_event', self.on_key_press) + self.canvas.mpl_connect('scroll_event', self.on_scroll) + self.canvas.setContextMenuPolicy(Qt.CustomContextMenu) + self.canvas.customContextMenuRequested.connect(self.show_canvas_context_menu) + return center_widget + + def create_control_panel(self) -> QWidget: + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + + # Display Options + display_box = CollapsibleBox("Display Options") + display_group = QWidget() + display_layout = QVBoxLayout(display_group) + self.grid_check = QCheckBox("Show Grid") + self.grid_check.setChecked(True) + self.grid_check.stateChanged.connect(self.toggle_grid) + display_layout.addWidget(self.grid_check) + self.legend_check = QCheckBox("Show Legend") + self.legend_check.setChecked(False) + self.legend_check.stateChanged.connect(self.toggle_legend) + display_layout.addWidget(self.legend_check) + self.autoscale_check = QCheckBox("Autoscale") + self.autoscale_check.setChecked(True) + display_layout.addWidget(self.autoscale_check) + self.timing_check = QCheckBox("Digital Timing View") + self.timing_check.stateChanged.connect(self.on_timing_view_changed) + display_layout.addWidget(self.timing_check) + display_box.addWidget(display_group) + right_layout.addWidget(display_box) + + # Digital Timing Controls (UI Reverted to Original) + self.timing_box = CollapsibleBox("Digital Timing Controls") + timing_group = QWidget() + timing_layout = QVBoxLayout(timing_group) + threshold_layout = QHBoxLayout() + threshold_layout.addWidget(QLabel("Threshold:")) + self.threshold_spinbox = QDoubleSpinBox() + self.threshold_spinbox.setRange(-100, 100) + self.threshold_spinbox.setDecimals(3) + self.threshold_spinbox.setSingleStep(0.1) + self.threshold_spinbox.setSuffix(" V") + self.threshold_spinbox.setSpecialValueText("Auto") + self.threshold_spinbox.valueChanged.connect(self.on_threshold_changed) + threshold_layout.addWidget(self.threshold_spinbox) + timing_layout.addLayout(threshold_layout) + spacing_layout = QHBoxLayout() + spacing_layout.addWidget(QLabel("Spacing:")) + self.spacing_slider = QSlider(Qt.Horizontal) + self.spacing_slider.setRange(100, 200) + self.spacing_slider.setValue(120) + self.spacing_slider.valueChanged.connect(self.on_spacing_changed) + self.spacing_label = QLabel("1.2x") + spacing_layout.addWidget(self.spacing_slider) + spacing_layout.addWidget(self.spacing_label) + timing_layout.addLayout(spacing_layout) + self.timing_box.addWidget(timing_group) + self.timing_box.content_area.setEnabled(False) + right_layout.addWidget(self.timing_box) + + # Cursor Measurements + cursor_box = CollapsibleBox("Cursor Measurements") + cursor_group = QWidget() + cursor_layout = QVBoxLayout(cursor_group) + self.cursor1_label = QLabel("Cursor 1: Not set") + self.cursor2_label = QLabel("Cursor 2: Not set") + self.delta_label = QLabel("Delta: --") + cursor_layout.addWidget(self.cursor1_label) + cursor_layout.addWidget(self.cursor2_label) + cursor_layout.addWidget(self.delta_label) + self.clear_cursors_btn = QPushButton("Clear Cursors") + self.clear_cursors_btn.clicked.connect(self.clear_cursors) + cursor_layout.addWidget(self.clear_cursors_btn) + cursor_box.addWidget(cursor_group) + right_layout.addWidget(cursor_box) + + # Export Tools + export_box = CollapsibleBox("Export Tools") + export_group = QWidget() + export_layout = QVBoxLayout(export_group) + self.export_btn = QPushButton("Export Image") + self.export_btn.clicked.connect(self.export_image) + export_layout.addWidget(self.export_btn) + self.func_input = QLineEdit() + self.func_input.setPlaceholderText("e.g., v(in) + v(out)") + export_layout.addWidget(self.func_input) + self.plot_func_btn = QPushButton("Plot Function") + self.plot_func_btn.clicked.connect(self.plot_function) + export_layout.addWidget(self.plot_func_btn) + self.multimeter_btn = QPushButton("Multimeter") + self.multimeter_btn.clicked.connect(self.multi_meter) + export_layout.addWidget(self.multimeter_btn) + export_box.addWidget(export_group) + right_layout.addWidget(export_box) + + right_layout.addStretch() + return right_widget + + def create_menu_bar(self) -> None: + file_menu = self.menu_bar.addMenu('File') + export_action = QAction('Export Image...', self) + export_action.triggered.connect(self.export_image) + file_menu.addAction(export_action) + view_menu = self.menu_bar.addMenu('View') + zoom_in_action = QAction('Zoom In', self) + zoom_in_action.setShortcut('Ctrl++') + zoom_in_action.triggered.connect(self.zoom_in) + view_menu.addAction(zoom_in_action) + zoom_out_action = QAction('Zoom Out', self) + zoom_out_action.setShortcut('Ctrl+-') + zoom_out_action.triggered.connect(self.zoom_out) + view_menu.addAction(zoom_out_action) + reset_view_action = QAction('Reset View', self) + reset_view_action.setShortcut('Ctrl+0') + reset_view_action.triggered.connect(self.reset_view) + view_menu.addAction(reset_view_action) + + def load_simulation_data(self) -> None: + self.obj_dataext = DataExtraction() + self.plot_type = self.obj_dataext.openFile(self.file_path) + self.obj_dataext.computeAxes() + self.data_info = self.obj_dataext.numVals() + for i in range(0, self.data_info[0] - 1): + color_idx = i % len(self.color_palette) + self.color.append(self.color_palette[color_idx]) + self.volts_length = self.data_info[1] + if self.plot_type[0] == DataExtraction.AC_ANALYSIS: + self.analysis_label.setText("AC Analysis") + elif self.plot_type[0] == DataExtraction.TRANSIENT_ANALYSIS: + self.analysis_label.setText("Transient Analysis") + else: + self.analysis_label.setText("DC Analysis") + for i, name in enumerate(self.obj_dataext.NBList): + self.trace_names[i] = name + self.populate_waveform_list() + + def create_colored_icon(self, color: QColor, is_selected: bool) -> QtGui.QIcon: + pixmap = QPixmap(18, 18) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + if is_selected: + painter.setBrush(QBrush(color)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(1, 1, 16, 16) + else: + painter.setBrush(Qt.NoBrush) + pen = QtGui.QPen(QColor("#9E9E9E")) + pen.setWidth(1) + painter.setPen(pen) + painter.drawEllipse(2, 2, 14, 14) + painter.end() + return QtGui.QIcon(pixmap) + + def populate_waveform_list(self) -> None: + self.waveform_list.clear() + saved_colors = self.config.get('trace_colours', {}) + saved_thickness = self.config.get('trace_thickness', {}) + saved_style = self.config.get('trace_style', {}) + for i, node_name in enumerate(self.obj_dataext.NBList): + item = QListWidgetItem() + item.setData(Qt.UserRole, i) + if node_name in saved_colors: + self.trace_colors[i] = saved_colors[node_name] + elif i < len(self.color): + self.trace_colors[i] = self.color[i] + else: + color_idx = i % len(self.color_palette) + self.trace_colors[i] = self.color_palette[color_idx] + if node_name in saved_thickness: + self.trace_thickness[i] = saved_thickness[node_name] + else: + self.trace_thickness[i] = DEFAULT_LINE_THICKNESS + if node_name in saved_style: + self.trace_style[i] = saved_style[node_name] + else: + self.trace_style[i] = '-' + item.setToolTip("Voltage signal" if i < self.obj_dataext.volts_length else "Current signal") + self.trace_visibility[i] = False + self.waveform_list.addItem(item) + self.update_list_item_appearance(item, i) + + def filter_waveforms(self, text: str) -> None: + for i in range(self.waveform_list.count()): + item = self.waveform_list.item(i) + if item: + item.setHidden(text.lower() not in item.text().lower()) + + def on_waveform_toggle(self, item: QListWidgetItem) -> None: + index = item.data(Qt.UserRole) + self.trace_visibility[index] = item.isSelected() + if item.isSelected() and index not in self.trace_colors: + self.assign_trace_color(index) + self.update_list_item_appearance(item, index) + self.refresh_plot() + + def assign_trace_color(self, index: int) -> None: + used_colors = set(self.trace_colors.values()) + available_colors = [color for color in self.color_palette if color not in used_colors] + if available_colors: + self.trace_colors[index] = available_colors[0] + else: + hue = (0.618033988749895 * len(self.trace_colors)) % 1.0 + color = QtGui.QColor.fromHsvF(hue, 0.7, 0.8) + self.trace_colors[index] = color.name() + self.save_config() + + def update_list_item_appearance(self, item: QListWidgetItem, index: int) -> None: + node_name = self.trace_names.get(index, self.obj_dataext.NBList[index]) + is_selected = self.trace_visibility.get(index, False) + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(6, 4, 6, 4) + layout.setSpacing(10) + icon_label = QLabel() + color = QColor(self.trace_colors[index]) if is_selected and index in self.trace_colors else QColor("#9E9E9E") + icon = self.create_colored_icon(color, is_selected) + icon_label.setPixmap(icon.pixmap(18, 18)) + text_label = QLabel(node_name) + text_label.setStyleSheet("color: #212121; font-weight: 500;" if is_selected and index in self.trace_colors else "color: #757575; font-weight: normal;") + layout.addWidget(icon_label) + layout.addWidget(text_label) + layout.addStretch() + self.waveform_list.setItemWidget(item, widget) + item.setText(node_name) + + def select_all_waveforms(self) -> None: + for i in range(self.waveform_list.count()): + item = self.waveform_list.item(i) + if item and not item.isHidden(): + item.setSelected(True) + index = item.data(Qt.UserRole) + self.trace_visibility[index] = True + if index not in self.trace_colors: + self.assign_trace_color(index) + self.update_list_item_appearance(item, index) + self.refresh_plot() + + def deselect_all_waveforms(self) -> None: + self.waveform_list.clearSelection() + for index in self.trace_visibility: + self.trace_visibility[index] = False + for i in range(self.waveform_list.count()): + item = self.waveform_list.item(i) + if item: + index = item.data(Qt.UserRole) + self.update_list_item_appearance(item, index) + self.refresh_plot() + + def show_list_context_menu(self, position: QtCore.QPoint) -> None: + item = self.waveform_list.itemAt(position) + if not item: + return + + # Always work with just the right-clicked item + menu = QMenu() + + # All menus apply only to the right-clicked item + color_menu = menu.addMenu("Change colour ▶") + self.populate_color_menu(color_menu, [item]) + + thickness_menu = menu.addMenu("Thickness ▶") + for thickness, label in THICKNESS_OPTIONS: + action = thickness_menu.addAction(label) + action.triggered.connect(lambda checked, t=thickness: self.change_thickness([item], t)) + + style_menu = menu.addMenu("Style ▶") + for style, label in LINE_STYLES: + action = style_menu.addAction(label) + action.triggered.connect(lambda checked, s=style: self.change_style([item], s)) + + menu.addSeparator() + + rename_action = menu.addAction("Rename...") + rename_action.triggered.connect(lambda: self.rename_trace(item)) + + index = item.data(Qt.UserRole) + visible = False + if index in self.active_traces and self.active_traces[index]: + visible = self.active_traces[index].get_visible() + + hide_show_action = menu.addAction("Show" if not visible else "Hide") + if visible: + hide_show_action.setCheckable(True) + hide_show_action.setChecked(True) + hide_show_action.triggered.connect(lambda: self.toggle_trace_visibility([item])) + + menu.addSeparator() + + properties_action = menu.addAction("Figure Options...") + properties_action.triggered.connect(self.open_figure_options) + + menu.exec_(self.waveform_list.mapToGlobal(position)) + + def show_canvas_context_menu(self, position: QtCore.QPoint) -> None: + menu = QMenu() + export_action = menu.addAction("Export Image...") + export_action.triggered.connect(self.export_image) + menu.addSeparator() + clear_action = menu.addAction("Clear Plot") + clear_action.triggered.connect(self.clear_plot) + menu.exec_(self.canvas.mapToGlobal(position)) + + def populate_color_menu(self, menu: QMenu, selected_items: List[QListWidgetItem]) -> None: + color_widget = QWidget() + color_widget.setStyleSheet("background-color: #FFFFFF;") + grid_layout = QGridLayout(color_widget) + grid_layout.setSpacing(2) + for i, color in enumerate(self.color_palette): + btn = QPushButton() + btn.setFixedSize(24, 24) + btn.setStyleSheet(f"QPushButton{{background-color:{color};border:1px solid #E0E0E0;border-radius:2px;}}QPushButton:hover{{border:2px solid #212121;}}") + btn.setCursor(Qt.PointingHandCursor) + btn.clicked.connect(lambda checked, c=color: self.change_color_and_close(selected_items, c, menu)) + grid_layout.addWidget(btn, i // 4, i % 4) + widget_action = QWidgetAction(menu) + widget_action.setDefaultWidget(color_widget) + menu.addAction(widget_action) + menu.addSeparator() + more_action = menu.addAction("More...") + more_action.triggered.connect(lambda: self.change_color_dialog(selected_items)) + + def change_color_and_close(self, items: List[QListWidgetItem], color: str, menu: QMenu) -> None: + self.change_color(items, color) + parent = menu.parent() + while isinstance(parent, QMenu): + parent.close() + parent = parent.parent() + + def change_color(self, items: List[QListWidgetItem], color: str) -> None: + for item in items: + index = item.data(Qt.UserRole) + self.trace_colors[index] = color + self.update_list_item_appearance(item, index) + if index in self.active_traces and self.active_traces[index]: + self.active_traces[index].set_color(color) + if self.timing_check.isChecked() and hasattr(self, 'axes'): + self.update_timing_tick_colors() + if hasattr(self, 'timing_annotations') and index in self.timing_annotations: + self.timing_annotations[index].set_color(color) + self.save_config() + self.canvas.draw() + + def update_timing_tick_colors(self) -> None: + if not hasattr(self, 'axes'): + return + visible_indices = [i for i, v in self.trace_visibility.items() if v] + ytick_labels = self.axes.get_yticklabels() + for i, label in enumerate(ytick_labels): + if i < len(visible_indices): + idx = visible_indices[::-1][i] + if idx in self.trace_colors: + label.set_color(self.trace_colors[idx]) + + def change_color_dialog(self, items: List[QListWidgetItem]) -> None: + color = QColorDialog.getColor() + if color.isValid(): + self.change_color(items, color.name()) + + def change_thickness(self, items: List[QListWidgetItem], thickness: float) -> None: + for item in items: + index = item.data(Qt.UserRole) + self.trace_thickness[index] = thickness + if index in self.active_traces and self.active_traces[index]: + self.active_traces[index].set_linewidth(thickness) + self.save_config() + self.canvas.draw() + + def change_style(self, items: List[QListWidgetItem], style: str) -> None: + for item in items: + index = item.data(Qt.UserRole) + self.trace_style[index] = style + if index in self.active_traces and self.active_traces[index]: + if style == 'steps-post': + self.refresh_plot() + return + else: + self.active_traces[index].set_linestyle(style) + self.save_config() + self.canvas.draw() + + def rename_trace(self, item: QListWidgetItem) -> None: + index = item.data(Qt.UserRole) + current_name = self.trace_names.get(index, self.obj_dataext.NBList[index]) + new_name, ok = QInputDialog.getText(self, "Rename Trace", "New name:", text=current_name) + if ok and new_name and new_name != current_name: + self.trace_names[index] = new_name + self.update_list_item_appearance(item, index) + self.obj_dataext.NBList[index] = new_name + if self.legend_check.isChecked(): + self.refresh_plot() + + def toggle_trace_visibility(self, items: List[QListWidgetItem]) -> None: + any_visible = any(item.data(Qt.UserRole) in self.active_traces and self.active_traces[item.data(Qt.UserRole)].get_visible() for item in items) + for item in items: + index = item.data(Qt.UserRole) + if index in self.active_traces and self.active_traces[index]: + self.active_traces[index].set_visible(not any_visible) + self.canvas.draw() + + def open_figure_options(self) -> None: + try: + if hasattr(self.fig.canvas, 'toolbar') and hasattr(self.fig.canvas.toolbar, 'edit_parameters'): + self.fig.canvas.toolbar.edit_parameters() + return + from matplotlib.backends.qt_compat import QtWidgets + from matplotlib.backends.qt_editor import _formlayout + if hasattr(_formlayout, 'FormDialog'): + options = [('Title', self.fig.suptitle('').get_text())] + if hasattr(self, 'axes'): + options.extend([('X Label', self.axes.get_xlabel()), ('Y Label', self.axes.get_ylabel()), ('X Min', self.axes.get_xlim()[0]), ('X Max', self.axes.get_xlim()[1]), ('Y Min', self.axes.get_ylim()[0]), ('Y Max', self.axes.get_ylim()[1])]) + dialog = _formlayout.FormDialog(options, parent=self, title='Figure Options') + if dialog.exec_(): + results = dialog.get_results() + if results: + self.fig.suptitle(results[0]) + if hasattr(self, 'axes') and len(results) > 1: + self.axes.set_xlabel(results[1]) + self.axes.set_ylabel(results[2]) + self.axes.set_xlim(results[3], results[4]) + self.axes.set_ylim(results[5], results[6]) + self.canvas.draw() + else: + QMessageBox.information(self, "Figure Options", "Figure options are limited in this environment.\nYou can use the zoom and pan tools in the toolbar.") + except Exception as e: + logger.error(f"Error opening figure options: {e}") + QMessageBox.information(self, "Figure Options", "Basic figure editing is available through the toolbar.") + + def on_timing_view_changed(self, state: int) -> None: + timing_enabled = state == Qt.Checked + self.timing_box.content_area.setEnabled(timing_enabled) + self.autoscale_check.setEnabled(not timing_enabled) + self.refresh_plot() + + def refresh_plot(self) -> None: + self.fig.clear() + self.active_traces.clear() + if self.timing_check.isChecked(): + self.axes = self.fig.add_subplot(111) + self.plot_timing_diagram() + else: + if self.plot_type[0] == DataExtraction.AC_ANALYSIS: + if self.plot_type[1] == 1: + self.on_push_decade() + else: + self.on_push_ac() + elif self.plot_type[0] == DataExtraction.TRANSIENT_ANALYSIS: + self.on_push_trans() + else: + self.on_push_dc() + if hasattr(self, 'axes'): + self.axes.grid(self.grid_check.isChecked()) + if self.legend_check.isChecked(): + plt.subplots_adjust(top=0.85, bottom=0.1) + self.position_legend() + else: + plt.subplots_adjust(top=0.95, bottom=0.1) + self.canvas.draw() + + def position_legend(self) -> None: + if hasattr(self, 'axes') and self.legend_check.isChecked(): + handles, labels = [], [] + for idx in sorted(self.trace_visibility.keys()): + if self.trace_visibility.get(idx) and idx in self.active_traces and self.active_traces[idx]: + handles.append(self.active_traces[idx]) + labels.append(self.trace_names.get(idx, self.obj_dataext.NBList[idx])) + if handles: + ncol = min(6, len(handles)) if len(handles) > 6 else min(4, len(handles)) + legend = self.axes.legend(handles, labels, bbox_to_anchor=(0.5, 1.02), loc='lower center', ncol=ncol, frameon=True, fancybox=False, shadow=False, fontsize=LEGEND_FONT_SIZE, borderaxespad=0, columnspacing=1.5) + frame = legend.get_frame() + frame.set_facecolor('white') + frame.set_edgecolor('#E0E0E0') + frame.set_linewidth(1) + frame.set_alpha(0.95) + + def plot_timing_diagram(self) -> None: + """ + Plot digital timing diagram with proper time offset handling. + + This method now correctly handles transient analysis with non-zero start times + by detecting and applying the appropriate time offset. + """ + # Clear any existing timing annotations + self.timing_annotations.clear() + + visible_indices = [i for i, v in self.trace_visibility.items() if v] + if not visible_indices: + self.axes.text(0.5, 0.5, 'Select a waveform to display', + ha='center', va='center', transform=self.axes.transAxes) + self.axes.set_yticks([]) + self.axes.set_yticklabels([]) + return + + # Collect all voltage data for threshold calculation + all_voltage_data = [] + for idx in visible_indices: + if idx < self.obj_dataext.volts_length: + all_voltage_data.extend(self.obj_dataext.y[idx]) + + # If no voltage data, use current data + if not all_voltage_data: + for idx in visible_indices: + all_voltage_data.extend(self.obj_dataext.y[idx]) + + if not all_voltage_data: + return + + all_voltage_data = np.array(all_voltage_data, dtype=float) + vmin = np.min(all_voltage_data) + vmax = np.max(all_voltage_data) + + # Handle threshold setting + if self.threshold_spinbox.value() == self.threshold_spinbox.minimum(): + self.logic_threshold = vmin + 0.7 * (vmax - vmin) + self.threshold_spinbox.setSpecialValueText(f"Auto ({self.logic_threshold:.3f} V)") + else: + self.logic_threshold = self.threshold_spinbox.value() + + # Get time data + time_data = np.asarray(self.obj_dataext.x, dtype=float) + + # CRITICAL FIX: Detect and handle transient analysis time offset + # For transient analysis with .tran step stop start, we need to find + # where the actual analysis begins + + # Check if this is a transient analysis + if self.plot_type[0] == DataExtraction.TRANSIENT_ANALYSIS: + # Read the analysis file to get the actual start time + try: + with open(os.path.join(self.file_path, "analysis"), 'r') as f: + analysis_content = f.read().strip() + + # Parse .tran command: .tran step stop start + if analysis_content.startswith('.tran'): + parts = analysis_content.split() + if len(parts) >= 4: + try: + # Convert scientific notation to float + start_time = float(parts[3]) + + # If start_time is not 0, we need to offset our data + if start_time > 0: + # Find the index where time >= start_time + start_idx = np.searchsorted(time_data, start_time) + + # Adjust time_data to start from the correct point + if start_idx > 0 and start_idx < len(time_data): + time_data = time_data[start_idx:] + + # Also adjust all data arrays + for idx in list(self.obj_dataext.y.keys()): + if idx < len(self.obj_dataext.y): + self.obj_dataext.y[idx] = self.obj_dataext.y[idx][start_idx:] + except (ValueError, IndexError): + pass # If parsing fails, use full data + except Exception as e: + logger.debug(f"Could not parse analysis file for time offset: {e}") + + # Prepare spacing for multiple traces + spacing_ref = max(1.0, vmax) + spacing = self.vertical_spacing * spacing_ref + yticks, ylabels = [], [] + + # Calculate annotation offset based on time range + annotation_offset_base = 0.01 * (time_data[-1] - time_data[0]) if len(time_data) > 1 else 0.01 + + # Plot each visible trace as a digital signal + for rank, idx in enumerate(visible_indices[::-1]): + # Get the raw data for this trace + raw_data = np.asarray(self.obj_dataext.y[idx], dtype=float) + + # Make sure raw_data matches time_data length after offset adjustment + if len(raw_data) > len(time_data): + raw_data = raw_data[:len(time_data)] + elif len(raw_data) < len(time_data): + # This shouldn't happen, but handle it gracefully + time_data = time_data[:len(raw_data)] + + trace_vmin, trace_vmax = np.min(raw_data), np.max(raw_data) + + # Convert to digital logic levels + logic_data = np.where(raw_data > self.logic_threshold, trace_vmax, trace_vmin) + + # Apply vertical offset for stacking + logic_offset = logic_data + rank * spacing + + # Get trace properties + color = self.trace_colors.get(idx, 'blue') + thickness = self.trace_thickness.get(idx, DEFAULT_LINE_THICKNESS) + label = self.trace_names.get(idx, self.obj_dataext.NBList[idx]) + + # Plot the digital waveform + line, = self.axes.step(time_data, logic_offset, where="post", + linewidth=thickness, color=color, label=label) + self.active_traces[idx] = line + + # Add y-axis tick for this trace + y_center = rank * spacing + (trace_vmax + trace_vmin) / 2.0 + yticks.append(y_center) + ylabels.append(label) + + # Add voltage annotation at the end + # Add voltage annotation at the right edge of the graph + # Add voltage annotation at the right edge of the graph + if len(raw_data) > 0: + final_voltage = f"{float(raw_data[-1]):.3f} V" + # Position the text at the right edge of the plot area + # Using transform coordinates: 1.01 means just outside the right edge + text_obj = self.axes.text(1.01, y_center, final_voltage, + transform=self.axes.get_yaxis_transform(), + va='center', ha='left', + fontsize=8, color=color, + clip_on=False) # This allows text to appear outside axes + self.timing_annotations[idx] = text_obj + + # Set y-axis limits and labels + total_height = (len(visible_indices) - 1) * spacing + vmax + self.axes.set_ylim(vmin - 0.1 * spacing_ref, total_height + 0.1 * spacing_ref) + self.axes.set_yticks(yticks) + self.axes.set_yticklabels(ylabels, fontsize=8) + + # Update tick colors to match trace colors + self.update_timing_tick_colors() + + # Set time axis with proper units + self.set_time_axis_label() + + # Add threshold line + self.axes.axhline(y=self.logic_threshold, color='red', linestyle=':', + alpha=THRESHOLD_ALPHA, linewidth=1) + + # Add title if legend is not shown + if not self.legend_check.isChecked(): + self.axes.set_title(f'Digital Timing Diagram (Threshold: {self.logic_threshold:.3f} V)', + fontsize=10, pad=10) + def set_time_axis_label(self) -> None: + if not hasattr(self, 'axes') or not hasattr(self.obj_dataext, 'x'): + return + time_data = np.array(self.obj_dataext.x, dtype=float) + if len(time_data) < 2: + self.axes.set_xlabel('Time (s)', fontsize=10) + return + time_span = abs(time_data[-1] - time_data[0]) + if time_span == 0: + scale, unit = 1, 's' + elif time_span < TIME_UNIT_THRESHOLD_PS: + scale, unit = 1e12, 'ps' + elif time_span < TIME_UNIT_THRESHOLD_NS: + scale, unit = 1e9, 'ns' + elif time_span < TIME_UNIT_THRESHOLD_US: + scale, unit = 1e6, 'µs' + elif time_span < TIME_UNIT_THRESHOLD_MS: + scale, unit = 1e3, 'ms' + else: + scale, unit = 1, 's' + scaled_time = time_data * scale + for line in self.active_traces.values(): + if line: + y_data = line.get_ydata() + # Step plots have one more y-value than x-value + if len(y_data) == len(scaled_time) + 1: + x_step_data = np.append(scaled_time, scaled_time[-1]) + line.set_data(x_step_data, y_data) + elif len(y_data) == len(scaled_time): + line.set_xdata(scaled_time) + + self.axes.set_xlim(scaled_time[0], scaled_time[-1]) + if hasattr(self, 'cursor_lines'): + for i, line in enumerate(self.cursor_lines): + if line and i < len(self.cursor_positions) and self.cursor_positions[i] is not None: + line.set_xdata([self.cursor_positions[i] * scale, self.cursor_positions[i] * scale]) + self.axes.set_xlabel(f'Time ({unit})', fontsize=10) + + def on_threshold_changed(self, value: float) -> None: + if self.timing_check.isChecked(): + self.refresh_plot() + + def on_spacing_changed(self, value: int) -> None: + self.vertical_spacing = value / 100.0 + self.spacing_label.setText(f"{self.vertical_spacing:.1f}x") + if self.timing_check.isChecked(): + self.refresh_plot() + + def on_canvas_click(self, event) -> None: + if hasattr(self, 'axes') and event.inaxes == self.axes: + if event.button == 1: + self.set_cursor(0, event.xdata) + elif event.button == 3: + self.set_cursor(1, event.xdata) + + def set_cursor(self, cursor_num: int, x_pos_scaled: float) -> None: + time_data = np.array(self.obj_dataext.x, dtype=float) + time_span = abs(time_data[-1] - time_data[0]) if len(time_data) > 1 else 0 + if time_span < TIME_UNIT_THRESHOLD_PS: scale = 1e12 + elif time_span < TIME_UNIT_THRESHOLD_NS: scale = 1e9 + elif time_span < TIME_UNIT_THRESHOLD_US: scale = 1e6 + elif time_span < TIME_UNIT_THRESHOLD_MS: scale = 1e3 + else: scale = 1 + x_pos_original = x_pos_scaled / scale + + if cursor_num < len(self.cursor_lines) and self.cursor_lines[cursor_num]: + self.cursor_lines[cursor_num].remove() + + color = 'red' if cursor_num == 0 else 'blue' + line = self.axes.axvline(x=x_pos_scaled, color=color, linestyle='--', alpha=CURSOR_ALPHA) + + if cursor_num >= len(self.cursor_lines): + self.cursor_lines.append(line) + self.cursor_positions.append(x_pos_original) + else: + self.cursor_lines[cursor_num] = line + self.cursor_positions[cursor_num] = x_pos_original + + label_widget = self.cursor1_label if cursor_num == 0 else self.cursor2_label + label_widget.setText(f"Cursor {cursor_num + 1}: {x_pos_scaled:.6g}") + + if len(self.cursor_positions) >= 2 and all(p is not None for p in self.cursor_positions[:2]): + delta_original = abs(self.cursor_positions[1] - self.cursor_positions[0]) + delta_scaled = delta_original * scale + self.delta_label.setText(f"Delta: {delta_scaled:.6g}") + if delta_original > 0: + freq_delta = 1.0 / delta_original + self.measure_label.setText(f"Freq: {freq_delta:.6g} Hz") + self.canvas.draw() + + def clear_cursors(self) -> None: + for line in self.cursor_lines: + if line: + line.remove() + self.cursor_lines.clear() + self.cursor_positions.clear() + self.cursor1_label.setText("Cursor 1: Not set") + self.cursor2_label.setText("Cursor 2: Not set") + self.delta_label.setText("Delta: --") + self.measure_label.setText("") + self.canvas.draw() + + def on_mouse_move(self, event) -> None: + if event.inaxes: + self.coord_label.setText(f"X: {event.xdata:.6g}, Y: {event.ydata:.6g}") + else: + self.coord_label.setText("X: --, Y: --") + + def on_key_press(self, event) -> None: + if event.key == 'g': self.grid_check.toggle() + elif event.key == 'l': self.legend_check.toggle() + elif event.key == 'p': self.open_figure_options() + elif event.key == 'escape': self.clear_cursors() + + def on_scroll(self, event) -> None: + if not event.inaxes: return + xlim, ylim = event.inaxes.get_xlim(), event.inaxes.get_ylim() + zoom_factor = DEFAULT_ZOOM_FACTOR if event.button == 'up' else 1 / DEFAULT_ZOOM_FACTOR + if event.key == 'control': + x_center, y_center = event.xdata, event.ydata + x_range, y_range = (xlim[1] - xlim[0]) * zoom_factor, (ylim[1] - ylim[0]) * zoom_factor + x_ratio, y_ratio = (x_center - xlim[0]) / (xlim[1] - xlim[0]), (y_center - ylim[0]) / (ylim[1] - ylim[0]) + event.inaxes.set_xlim(x_center - x_range * x_ratio, x_center + x_range * (1 - x_ratio)) + event.inaxes.set_ylim(y_center - y_range * y_ratio, y_center + y_range * (1 - y_ratio)) + elif event.key == 'shift': + pan_distance = (xlim[1] - xlim[0]) * 0.1 * (-1 if event.button == 'up' else 1) + event.inaxes.set_xlim(xlim[0] + pan_distance, xlim[1] + pan_distance) + self.canvas.draw() + + def export_image(self) -> None: + file_name, file_filter = QFileDialog.getSaveFileName(self, "Export Image", "", "PNG Files (*.png);;SVG Files (*.svg);;All Files (*)") + if file_name: + try: + format = 'svg' if "svg" in file_filter else 'png' + if '.' not in os.path.basename(file_name): file_name += f'.{format}' + self.fig.savefig(file_name, format=format, dpi=DEFAULT_EXPORT_DPI, bbox_inches='tight') + self.status_bar.showMessage(f"Image exported to {file_name}", 3000) + except Exception as e: + logger.error(f"Error exporting image: {e}") + QMessageBox.warning(self, "Export Error", f"Failed to export image: {str(e)}") + + def clear_plot(self) -> None: + self.timing_annotations.clear() + self.deselect_all_waveforms() + + def zoom_in(self) -> None: + if hasattr(self, 'axes'): self.nav_toolbar.zoom() + + def zoom_out(self) -> None: + if hasattr(self, 'axes'): self.nav_toolbar.back() + + def reset_view(self) -> None: + if hasattr(self, 'axes'): self.nav_toolbar.home() + + def toggle_grid(self) -> None: + if hasattr(self, 'axes'): + self.axes.grid(self.grid_check.isChecked()) + self.canvas.draw() + + def toggle_legend(self) -> None: + self.refresh_plot() + + def plot_function(self) -> None: + # This function remains complex, will copy simplified logic if possible + # For now, keeping the original logic + function_text = self.func_input.text() + if not function_text: + QMessageBox.warning(self, "Input Error", "Function input cannot be empty.") + return + + # Basic parsing (this is a simplified example, not a full math parser) + # It expects "trace1 vs trace2" or a simple expression with +, -, *, / + # For security, avoid using eval() directly on user input in production. + # This implementation is for a controlled environment. + + if 'vs' in function_text: + parts = [p.strip() for p in function_text.split('vs')] + if len(parts) != 2: + QMessageBox.warning(self, "Syntax Error", "Use format 'trace1 vs trace2'.") + return + y_name, x_name = parts[0], parts[1] + try: + x_idx = self.obj_dataext.NBList.index(x_name) + y_idx = self.obj_dataext.NBList.index(y_name) + x_data = np.array(self.obj_dataext.y[x_idx], dtype=float) + y_data = np.array(self.obj_dataext.y[y_idx], dtype=float) + + is_voltage_x = x_idx < self.volts_length + is_voltage_y = y_idx < self.volts_length + + self.axes.plot(x_data, y_data, label=function_text) + self.axes.set_xlabel(f"{x_name} ({'V' if is_voltage_x else 'A'})") + self.axes.set_ylabel(f"{y_name} ({'V' if is_voltage_y else 'A'})") + + except ValueError: + QMessageBox.warning(self, "Trace Not Found", f"Could not find one of the traces: {x_name}, {y_name}") + return + else: + # Simple expression evaluation (use with caution) + try: + # Replace trace names with data arrays + result_expr = function_text + for i, name in enumerate(self.obj_dataext.NBList): + if name in result_expr: + result_expr = result_expr.replace(name, f"np.array(self.obj_dataext.y[{i}], dtype=float)") + + # Evaluate the expression + y_data = eval(result_expr, {"np": np, "self": self}) + x_data = np.array(self.obj_dataext.x, dtype=float) + self.axes.plot(x_data, y_data, label=function_text) + + except Exception as e: + QMessageBox.warning(self, "Evaluation Error", f"Could not plot function: {e}") + return + + if self.legend_check.isChecked(): + self.position_legend() + self.canvas.draw() + + + def multi_meter(self) -> None: + visible_indices = [i for i, v in self.trace_visibility.items() if v] + if not visible_indices: + QMessageBox.warning(self, "Warning", "Please select at least one waveform") + return + location_x, location_y = 300, 300 + for idx in visible_indices: + is_voltage = idx < self.obj_dataext.volts_length + rms_value = self.get_rms_value(self.obj_dataext.y[idx]) + meter = MultimeterWidgetClass(self.trace_names.get(idx, self.obj_dataext.NBList[idx]), rms_value, location_x, location_y, is_voltage) + if hasattr(self.obj_appconfig, 'dock_dict') and self.obj_appconfig.current_project['ProjectName'] in self.obj_appconfig.dock_dict: + self.obj_appconfig.dock_dict[self.obj_appconfig.current_project['ProjectName']].append(meter) + location_x += 50 + location_y += 50 + + def get_rms_value(self, data_points: List) -> Decimal: + getcontext().prec = 5 + return Decimal(str(np.sqrt(np.mean(np.square([float(x) for x in data_points]))))) + + def redraw_cursors(self) -> None: + # This function might be redundant if set_time_axis_label handles cursor redraws + pass + + def _plot_analysis_data(self, analysis_type: str) -> None: + self.axes = self.fig.add_subplot(111) + traces_plotted = 0 + for trace_index, is_visible in self.trace_visibility.items(): + if not is_visible: + continue + traces_plotted += 1 + color = self.trace_colors.get(trace_index, '#000000') + label = self.trace_names.get(trace_index, self.obj_dataext.NBList[trace_index]) + thickness = self.trace_thickness.get(trace_index, DEFAULT_LINE_THICKNESS) + style = self.trace_style.get(trace_index, '-') + x_data = np.asarray(self.obj_dataext.x, dtype=float) + y_data = np.asarray(self.obj_dataext.y[trace_index], dtype=float) + + plot_style = '-' if style == 'steps-post' else style + plot_func = self.axes.plot + if style == 'steps-post' and analysis_type in ['transient', 'dc']: + plot_func = self.axes.step + elif analysis_type == 'ac_log': + plot_func = self.axes.semilogx + + line, = plot_func(x_data, y_data, c=color, label=label, linewidth=thickness, linestyle=plot_style) + self.active_traces[trace_index] = line + + if analysis_type in ['ac_linear', 'ac_log']: + self.axes.set_xlabel('Frequency (Hz)') + elif analysis_type == 'transient': + # set_time_axis_label is now called from refresh_plot + pass + elif analysis_type == 'dc': + self.axes.set_xlabel('Voltage Sweep (V)') + + # Set Y label based on the first plotted trace + first_visible = next((i for i, v in self.trace_visibility.items() if v), None) + if first_visible is not None: + self.axes.set_ylabel('Voltage (V)' if first_visible < self.volts_length else 'Current (A)') + + if traces_plotted == 0: + self.axes.text(0.5, 0.5, 'Please select a waveform to plot', ha='center', va='center', transform=self.axes.transAxes) + + if analysis_type == 'transient': + self.set_time_axis_label() + + + def on_push_decade(self) -> None: + self._plot_analysis_data('ac_log') + + def on_push_ac(self) -> None: + self._plot_analysis_data('ac_linear') + + def on_push_trans(self) -> None: + self._plot_analysis_data('transient') + + def on_push_dc(self) -> None: + self._plot_analysis_data('dc') + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + super().resizeEvent(event) + if self.parent(): + self.parent().updateGeometry() + if hasattr(self, 'canvas') and self.canvas: + self.canvas.draw_idle() + + def sizeHint(self) -> QtCore.QSize: + return QtCore.QSize(1200, 800) + + def minimumSizeHint(self) -> QtCore.QSize: + return QtCore.QSize(400, 300) diff --git a/src/ngspiceSimulation/plotting_widgets.py b/src/ngspiceSimulation/plotting_widgets.py new file mode 100644 index 000000000..cb0386d72 --- /dev/null +++ b/src/ngspiceSimulation/plotting_widgets.py @@ -0,0 +1,240 @@ +""" +Plotting Widgets Module + +This module provides custom widgets for the plotting interface including +collapsible boxes and multimeter widgets. +""" + +import logging +from typing import Optional +from decimal import Decimal +from PyQt5 import QtCore, QtWidgets +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QToolButton, + QGridLayout, QLabel) + +# Set up logging +logger = logging.getLogger(__name__) + +# Default widget dimensions +DEFAULT_WIDGET_WIDTH = 300 +DEFAULT_WIDGET_HEIGHT = 100 + + +class CollapsibleBox(QWidget): + """ + A collapsible widget container with a toggle button. + + This widget provides a collapsible container with a title button that + can be clicked to show/hide the content area. + """ + + def __init__(self, title: str = "", parent: Optional[QWidget] = None) -> None: + """ + Initialize the CollapsibleBox widget. + + Args: + title: Title text to display on the toggle button + parent: Parent widget + """ + super().__init__(parent) + + self.title = title + # **FIX**: Set proper size policy + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Maximum) + + self._setup_toggle_button() + self._setup_content_area() + self._setup_layout() + self._connect_signals() + + def _setup_toggle_button(self) -> None: + """Set up the toggle button with styling and properties.""" + self.toggle_button = QToolButton() + self.toggle_button.setStyleSheet("QToolButton { border: none; }") + self.toggle_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.toggle_button.setArrowType(Qt.DownArrow) + self.toggle_button.setText(self.title) + self.toggle_button.setCheckable(True) + self.toggle_button.setChecked(True) + + def _setup_content_area(self) -> None: + """Set up the content area and its layout.""" + self.content_area = QWidget() + self.content_layout = QVBoxLayout() + self.content_area.setLayout(self.content_layout) + + def _setup_layout(self) -> None: + """Set up the main layout for the widget.""" + main_layout = QVBoxLayout(self) + main_layout.setSpacing(0) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(self.toggle_button) + main_layout.addWidget(self.content_area) + + def _connect_signals(self) -> None: + """Connect widget signals to their handlers.""" + self.toggle_button.toggled.connect(self.on_toggle) + + def on_toggle(self, is_checked: bool) -> None: + """ + Handle toggle button state changes. + + Args: + is_checked: Whether the toggle button is checked + """ + arrow_type = Qt.DownArrow if is_checked else Qt.RightArrow + self.toggle_button.setArrowType(arrow_type) + self.content_area.setVisible(is_checked) + + logger.debug(f"CollapsibleBox '{self.title}' {'expanded' if is_checked else 'collapsed'}") + + def addWidget(self, widget: QWidget) -> None: + """ + Add a widget to the content area. + + Args: + widget: Widget to add to the content layout + """ + if widget is not None: + self.content_layout.addWidget(widget) + else: + logger.warning("Attempted to add None widget to CollapsibleBox") + + def addLayout(self, layout) -> None: + """ + Add a layout to the content area. + + Args: + layout: Layout to add to the content layout + """ + if layout is not None: + self.content_layout.addLayout(layout) + else: + logger.warning("Attempted to add None layout to CollapsibleBox") + + +class MultimeterWidgetClass(QWidget): + """ + A multimeter widget for displaying RMS values of voltage or current signals. + + This widget provides a digital multimeter-like interface showing the RMS + value of a selected node or branch along with its label. + """ + + # Unit labels + VOLTAGE_UNIT = "Volts" + CURRENT_UNIT = "Amp" + + # Labels + NODE_LABEL = "Node" + BRANCH_LABEL = "Branch" + RMS_LABEL = "RMS Value" + WINDOW_TITLE = "MultiMeter" + + def __init__(self, node_branch: str, rms_value: Decimal, + location_x: int, location_y: int, is_voltage: bool) -> None: + """ + Initialize the MultimeterWidget. + + Args: + node_branch: Name of the node or branch being measured + rms_value: RMS value to display + location_x: X coordinate for widget positioning + location_y: Y coordinate for widget positioning + is_voltage: True if measuring voltage, False for current + """ + super().__init__() + + # **FIX**: Don't force window size, let it be managed by parent + self.node_branch = node_branch + self.rms_value = rms_value + self.location_x = location_x + self.location_y = location_y + self.is_voltage = is_voltage + + # Set proper size policy instead of fixed geometry + self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed) + + self._setup_ui() + self._configure_window() + + logger.info(f"Created multimeter widget for {'voltage' if is_voltage else 'current'}: " + f"{node_branch} = {rms_value}") + + def _setup_ui(self) -> None: + """Set up the user interface elements.""" + # Create main container widget + self.multimeter_container = QWidget(self) + + # Create labels based on measurement type + self._create_labels() + + # Set up layout + self._setup_layout() + + def _create_labels(self) -> None: + """Create and configure the display labels.""" + # Create type label (Node or Branch) + if self.is_voltage: + self.type_label = QLabel(self.NODE_LABEL) + unit_text = self.VOLTAGE_UNIT + else: + self.type_label = QLabel(self.BRANCH_LABEL) + unit_text = self.CURRENT_UNIT + + # Create value labels + self.rms_title_label = QLabel(self.RMS_LABEL) + self.node_branch_value_label = QLabel(str(self.node_branch)) + self.rms_value_label = QLabel(f"{self.rms_value} {unit_text}") + + def _setup_layout(self) -> None: + """Set up the grid layout for the widget.""" + layout = QGridLayout(self) + layout.addWidget(self.type_label, 0, 0) + layout.addWidget(self.rms_title_label, 0, 1) + layout.addWidget(self.node_branch_value_label, 1, 0) + layout.addWidget(self.rms_value_label, 1, 1) + + self.multimeter_container.setLayout(layout) + + def _configure_window(self) -> None: + """Configure window properties and display the widget.""" + self.setGeometry( + self.location_x, + self.location_y, + DEFAULT_WIDGET_WIDTH, + DEFAULT_WIDGET_HEIGHT + ) + self.setWindowTitle(self.WINDOW_TITLE) + self.setWindowFlags(Qt.WindowStaysOnTopHint) + self.show() + + def update_value(self, new_rms_value: Decimal) -> None: + """ + Update the displayed RMS value. + + Args: + new_rms_value: New RMS value to display + """ + self.rms_value = new_rms_value + unit_text = self.VOLTAGE_UNIT if self.is_voltage else self.CURRENT_UNIT + self.rms_value_label.setText(f"{new_rms_value} {unit_text}") + + logger.debug(f"Updated multimeter value: {self.node_branch} = {new_rms_value}") + + def get_measurement_info(self) -> dict: + """ + Get measurement information as a dictionary. + + Returns: + Dictionary containing measurement details + """ + return { + 'node_branch': self.node_branch, + 'rms_value': self.rms_value, + 'is_voltage': self.is_voltage, + 'unit': self.VOLTAGE_UNIT if self.is_voltage else self.CURRENT_UNIT + } diff --git a/src/ngspiceSimulation/pythonPlotting.py b/src/ngspiceSimulation/pythonPlotting.py deleted file mode 100644 index 615ad02b5..000000000 --- a/src/ngspiceSimulation/pythonPlotting.py +++ /dev/null @@ -1,810 +0,0 @@ -from __future__ import division # Used for decimal division -# eg: 2/3=0.66 and not '0' 6/2=3.0 and 6//2=3 -import os -from PyQt5 import QtGui, QtCore, QtWidgets -from decimal import Decimal, getcontext -from matplotlib.backends.backend_qt5agg\ - import FigureCanvasQTAgg as FigureCanvas -from matplotlib.backends.backend_qt5agg\ - import NavigationToolbar2QT as NavigationToolbar -from matplotlib.figure import Figure -from configuration.Appconfig import Appconfig -import numpy as np - - -# This class creates Python Plotting window -class plotWindow(QtWidgets.QMainWindow): - """ - This class defines python plotting window, its features, buttons, - colors, AC and DC analysis, plotting etc. - """ - - def __init__(self, fpath, projectName): - """This create constructor for plotWindow class.""" - QtWidgets.QMainWindow.__init__(self) - self.fpath = fpath - self.projectName = projectName - self.obj_appconfig = Appconfig() - print("Complete Project Path : ", self.fpath) - print("Project Name : ", self.projectName) - self.obj_appconfig.print_info( - 'Ngspice simulation is called : ' + self.fpath) - self.obj_appconfig.print_info( - 'PythonPlotting is called : ' + self.fpath) - self.combo = [] - self.combo1 = [] - self.combo1_rev = [] - # Creating Frame - self.createMainFrame() - - def createMainFrame(self): - self.mainFrame = QtWidgets.QWidget() - self.dpi = 100 - self.fig = Figure((7.0, 7.0), dpi=self.dpi) - # Creating Canvas which will figure - self.canvas = FigureCanvas(self.fig) - self.canvas.setParent(self.mainFrame) - self.axes = self.fig.add_subplot(111) - self.navToolBar = NavigationToolbar(self.canvas, self.mainFrame) - - # LeftVbox hold navigation tool bar and canvas - self.left_vbox = QtWidgets.QVBoxLayout() - self.left_vbox.addWidget(self.navToolBar) - self.left_vbox.addWidget(self.canvas) - - # right VBOX is main Layout which hold right grid(bottom part) and top - # grid(top part) - self.right_vbox = QtWidgets.QVBoxLayout() - self.right_grid = QtWidgets.QGridLayout() - self.top_grid = QtWidgets.QGridLayout() - - # Get DataExtraction Details - self.obj_dataext = DataExtraction() - self.plotType = self.obj_dataext.openFile(self.fpath) - - self.obj_dataext.computeAxes() - self.a = self.obj_dataext.numVals() - - self.chkbox = [] - - # Generating list of colors : - # ,(0.4,0.5,0.2),(0.1,0.4,0.9),(0.4,0.9,0.2),(0.9,0.4,0.9)] - self.full_colors = ['r', 'b', 'g', 'y', 'c', 'm', 'k'] - self.color = [] - for i in range(0, self.a[0] - 1): - if i % 7 == 0: - self.color.append(self.full_colors[0]) - elif (i - 1) % 7 == 0: - self.color.append(self.full_colors[1]) - elif (i - 2) % 7 == 0: - self.color.append(self.full_colors[2]) - elif (i - 3) % 7 == 0: - self.color.append(self.full_colors[3]) - elif (i - 4) % 7 == 0: - self.color.append(self.full_colors[4]) - elif (i - 5) % 7 == 0: - self.color.append(self.full_colors[5]) - elif (i - 6) % 7 == 0: - self.color.append(self.full_colors[6]) - - # Color generation ends here - - # Total number of voltage source - self.volts_length = self.a[1] - self.analysisType = QtWidgets.QLabel() - self.top_grid.addWidget(self.analysisType, 0, 0) - self.listNode = QtWidgets.QLabel() - self.top_grid.addWidget(self.listNode, 1, 0) - self.listBranch = QtWidgets.QLabel() - self.top_grid.addWidget(self.listBranch, self.a[1] + 2, 0) - for i in range(0, self.a[1]): # a[0]-1 - self.chkbox.append(QtWidgets.QCheckBox(self.obj_dataext.NBList[i])) - self.chkbox[i].setStyleSheet('color') - self.chkbox[i].setToolTip('Check To Plot') - self.top_grid.addWidget(self.chkbox[i], i + 2, 0) - self.colorLab = QtWidgets.QLabel() - self.colorLab.setText('____') - self.colorLab.setStyleSheet( - self.colorName( - self.color[i]) + - '; font-weight = bold;') - self.top_grid.addWidget(self.colorLab, i + 2, 1) - - for i in range(self.a[1], self.a[0] - 1): # a[0]-1 - self.chkbox.append(QtWidgets.QCheckBox(self.obj_dataext.NBList[i])) - self.chkbox[i].setToolTip('Check To Plot') - self.top_grid.addWidget(self.chkbox[i], i + 3, 0) - self.colorLab = QtWidgets.QLabel() - self.colorLab.setText('____') - self.colorLab.setStyleSheet( - self.colorName( - self.color[i]) + - '; font-weight = bold;') - self.top_grid.addWidget(self.colorLab, i + 3, 1) - - # Buttons for Plot, multimeter, plotting function. - self.clear = QtWidgets.QPushButton("Clear") - self.warnning = QtWidgets.QLabel() - self.funcName = QtWidgets.QLabel() - self.funcExample = QtWidgets.QLabel() - - self.plotbtn = QtWidgets.QPushButton("Plot") - self.plotbtn.setToolTip('Press to Plot') - self.multimeterbtn = QtWidgets.QPushButton("Multimeter") - self.multimeterbtn.setToolTip( - 'RMS value of the current and voltage is displayed') - self.text = QtWidgets.QLineEdit() - self.funcLabel = QtWidgets.QLabel() - self.palette1 = QtGui.QPalette() - self.palette2 = QtGui.QPalette() - self.plotfuncbtn = QtWidgets.QPushButton("Plot Function") - self.plotfuncbtn.setToolTip('Press to Plot the function') - - self.palette1.setColor(QtGui.QPalette.Foreground, QtCore.Qt.blue) - self.palette2.setColor(QtGui.QPalette.Foreground, QtCore.Qt.red) - self.funcName.setPalette(self.palette1) - self.funcExample.setPalette(self.palette2) - # Widgets for grid, plot button and multimeter button. - self.right_vbox.addLayout(self.top_grid) - self.right_vbox.addWidget(self.plotbtn) - self.right_vbox.addWidget(self.multimeterbtn) - - self.right_grid.addWidget(self.funcLabel, 1, 0) - self.right_grid.addWidget(self.text, 1, 1) - self.right_grid.addWidget(self.plotfuncbtn, 2, 1) - self.right_grid.addWidget(self.clear, 2, 0) - self.right_grid.addWidget(self.warnning, 3, 0) - self.right_grid.addWidget(self.funcName, 4, 0) - self.right_grid.addWidget(self.funcExample, 4, 1) - self.right_vbox.addLayout(self.right_grid) - - self.hbox = QtWidgets.QHBoxLayout() - self.hbox.addLayout(self.left_vbox) - self.hbox.addLayout(self.right_vbox) - - self.widget = QtWidgets.QWidget() - self.widget.setLayout(self.hbox) # finalvbox - self.scrollArea = QtWidgets.QScrollArea() - self.scrollArea.setWidgetResizable(True) - self.scrollArea.setWidget(self.widget) - ''' - Right side box containing checkbox for different inputs and - options of plot, multimeter and plot function. - ''' - self.finalhbox = QtWidgets.QHBoxLayout() - self.finalhbox.addWidget(self.scrollArea) - # Right side window frame showing list of nodes and branches. - self.mainFrame.setLayout(self.finalhbox) - self.showMaximized() - - self.listNode.setText("List of Nodes:") - self.listBranch.setText( - "List of Branches:") - self.funcLabel.setText("Function:") - self.funcName.setText( - "Standard functions\ -

Addition:
Subtraction:
\ - Multiplication:
Division:
Comparison:" - ) - self.funcExample.setText( - "\n\nNode1 + Node2\nNode1 - Node2\nNode1 * Node2\nNode1 / Node2\ - \nNode1 vs Node2") - - # Connecting to plot and clear function - self.clear.clicked.connect(self.pushedClear) - self.plotfuncbtn.clicked.connect(self.pushedPlotFunc) - self.multimeterbtn.clicked.connect(self.multiMeter) - - # for AC analysis - if self.plotType[0] == 0: - self.analysisType.setText("AC Analysis") - if self.plotType[1] == 1: - self.plotbtn.clicked.connect(self.onPush_decade) - else: - self.plotbtn.clicked.connect(self.onPush_ac) - # for transient analysis - elif self.plotType[0] == 1: - self.analysisType.setText("Transient Analysis") - self.plotbtn.clicked.connect(self.onPush_trans) - else: - # For DC analysis - self.analysisType.setText("DC Analysis") - self.plotbtn.clicked.connect(self.onPush_dc) - - self.setCentralWidget(self.mainFrame) - - # definition of functions pushedClear, pushedPlotFunc. - def pushedClear(self): - self.text.clear() - self.axes.cla() - self.canvas.draw() - - def pushedPlotFunc(self): - self.parts = str(self.text.text()) - self.parts = self.parts.split(" ") - - if self.parts[len(self.parts) - 1] == '': - self.parts = self.parts[0:-1] - - self.values = self.parts - self.comboAll = [] - self.axes.cla() - - self.plotType2 = self.obj_dataext.openFile(self.fpath) - - if len(self.parts) <= 2: - self.warnning.setText("Too few arguments!\nRefer syntax below!") - QtWidgets.QMessageBox.about( - self, "Warning!!", "Too Few Arguments/SYNTAX Error!\ - \n Refer Examples") - return - else: - self.warnning.setText("") - - a = [] - finalResult = [] - # p = 0 - - for i in range(len(self.parts)): - if i % 2 == 0: - for j in range(len(self.obj_dataext.NBList)): - if self.parts[i] == self.obj_dataext.NBList[j]: - a.append(j) - - if len(a) != len(self.parts) // 2 + 1: - QtWidgets.QMessageBox.about( - self, "Warning!!", - "One of the operands doesn't belong to " - "the above list of Nodes!!" - ) - return - - for i in a: - self.comboAll.append(self.obj_dataext.y[i]) - - for i in range(len(a)): - - if a[i] == len(self.obj_dataext.NBList): - QtWidgets.QMessageBox.about( - self, "Warning!!", "One of the operands doesn't belong " + - "to the above list!!" - ) - self.warnning.setText( - "To Err Is Human!
One of the " + - "operands doesn't belong to the above list!!
" - ) - return - - if self.parts[1] == 'vs': - if len(self.parts) > 3: - self.warnning.setText("Enter two operands only!!") - QtWidgets.QMessageBox.about( - self, "Warning!!", "Recheck the expression syntax!" - ) - return - else: - self.axes.cla() - - for i in range(len(self.obj_dataext.y[a[0]])): - self.combo.append(self.obj_dataext.y[a[0]][i]) - self.combo1.append(self.obj_dataext.y[a[1]][i]) - - self.axes.plot( - self.combo, - self.combo1, - c=self.color[1], - label=str(2)) # _rev - - if max(a) < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - self.axes.set_xlabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - self.axes.set_ylabel('Current(I)-->') - - elif max(a) >= self.volts_length and min(a) < self.volts_length: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Do not combine Voltage and Current!!" - ) - return - - else: - for j in range(len(self.comboAll[0])): - for i in range(len(self.values)): - if i % 2 == 0: - self.values[i] = str(self.comboAll[i // 2][j]) - re = " ".join(self.values[:]) - try: - finalResult.append(eval(re)) - except ArithmeticError: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Dividing by zero!!" - ) - return - - if self.plotType2[0] == 0: - # self.setWindowTitle('AC Analysis') - if self.plotType2[1] == 1: - self.axes.semilogx( - self.obj_dataext.x, - finalResult, - c=self.color[0], - label=str(1)) - else: - self.axes.plot( - self.obj_dataext.x, - finalResult, - c=self.color[0], - label=str(1)) - - self.axes.set_xlabel('freq-->') - - if max(a) < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - - elif self.plotType2[0] == 1: - # self.setWindowTitle('Transient Analysis') - self.axes.plot( - self.obj_dataext.x, - finalResult, - c=self.color[0], - label=str(1)) - self.axes.set_xlabel('time-->') - if max(a) < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - - else: - # self.setWindowTitle('DC Analysis') - self.axes.plot( - self.obj_dataext.x, - finalResult, - c=self.color[0], - label=str(1)) - self.axes.set_xlabel('I/P Voltage-->') - if max(a) < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - - self.axes.grid(True) - self.canvas.draw() - self.combo = [] - self.combo1 = [] - self.combo1_rev = [] - - # definition of functions onPush_decade, onPush_ac, onPush_trans,\ - # onPush_dc, color and multimeter and getRMSValue. - def onPush_decade(self): - boxCheck = 0 - self.axes.cla() - - for i, j in zip(self.chkbox, list(range(len(self.chkbox)))): - if i.isChecked(): - boxCheck += 1 - self.axes.semilogx( - self.obj_dataext.x, - self.obj_dataext.y[j], - c=self.color[j], - label=str( - j + 1)) - self.axes.set_xlabel('freq-->') - if j < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - - self.axes.grid(True) - if boxCheck == 0: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Please select at least one Node OR Branch" - ) - return - - self.canvas.draw() - - def onPush_ac(self): - self.axes.cla() - boxCheck = 0 - for i, j in zip(self.chkbox, list(range(len(self.chkbox)))): - if i.isChecked(): - boxCheck += 1 - self.axes.plot( - self.obj_dataext.x, - self.obj_dataext.y[j], - c=self.color[j], - label=str( - j + 1)) - self.axes.set_xlabel('freq-->') - if j < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - self.axes.grid(True) - if boxCheck == 0: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Please select at least one Node OR Branch" - ) - return - - self.canvas.draw() - - def onPush_trans(self): - self.axes.cla() - boxCheck = 0 - for i, j in zip(self.chkbox, list(range(len(self.chkbox)))): - if i.isChecked(): - boxCheck += 1 - self.axes.plot( - self.obj_dataext.x, - self.obj_dataext.y[j], - c=self.color[j], - label=str( - j + 1)) - self.axes.set_xlabel('time-->') - if j < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - self.axes.grid(True) - if boxCheck == 0: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Please select at least one Node OR Branch" - ) - return - self.canvas.draw() - - def onPush_dc(self): - boxCheck = 0 - self.axes.cla() - for i, j in zip(self.chkbox, list(range(len(self.chkbox)))): - if i.isChecked(): - boxCheck += 1 - self.axes.plot( - self.obj_dataext.x, - self.obj_dataext.y[j], - c=self.color[j], - label=str( - j + 1)) - self.axes.set_xlabel('Voltage Sweep(V)-->') - - if j < self.volts_length: - self.axes.set_ylabel('Voltage(V)-->') - else: - self.axes.set_ylabel('Current(I)-->') - self.axes.grid(True) - if boxCheck == 0: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Please select atleast one Node OR Branch" - ) - return - - self.canvas.draw() - - def colorName(self, letter): - return { - 'r': 'color:red', - 'b': 'color:blue', - 'g': 'color:green', - 'y': 'color:yellow', - 'c': 'color:cyan', - 'm': 'color:magenta', - 'k': 'color:black' - }[letter] - - def multiMeter(self): - print("Function : MultiMeter") - self.obj = {} - boxCheck = 0 - loc_x = 300 - loc_y = 300 - - for i, j in zip(self.chkbox, list(range(len(self.chkbox)))): - if i.isChecked(): - print("Check box", self.obj_dataext.NBList[j]) - boxCheck += 1 - if self.obj_dataext.NBList[j] in self.obj_dataext.NBIList: - voltFlag = False - else: - voltFlag = True - # Initializing Multimeter - self.obj[j] = MultimeterWidgetClass( - self.obj_dataext.NBList[j], self.getRMSValue( - self.obj_dataext.y[j]), loc_x, loc_y, voltFlag) - loc_x += 50 - loc_y += 50 - # Adding object of multimeter to dictionary - ( - self.obj_appconfig. - dock_dict[ - self.obj_appconfig.current_project['ProjectName']]. - append(self.obj[j]) - ) - - if boxCheck == 0: - QtWidgets.QMessageBox.about( - self, "Warning!!", "Please select at least one Node OR Branch" - ) - - def getRMSValue(self, dataPoints): - getcontext().prec = 5 - return np.sqrt(np.mean(np.square(dataPoints))) - - -class MultimeterWidgetClass(QtWidgets.QWidget): - def __init__(self, node_branch, rmsValue, loc_x, loc_y, voltFlag): - QtWidgets.QWidget.__init__(self) - - self.multimeter = QtWidgets.QWidget(self) - if voltFlag: - self.node_branchLabel = QtWidgets.QLabel("Node") - self.rmsValue = QtWidgets.QLabel(str(rmsValue) + " Volts") - else: - self.node_branchLabel = QtWidgets.QLabel("Branch") - self.rmsValue = QtWidgets.QLabel(str(rmsValue) + " Amp") - - self.rmsLabel = QtWidgets.QLabel("RMS Value") - self.nodeBranchValue = QtWidgets.QLabel(str(node_branch)) - - self.layout = QtWidgets.QGridLayout(self) - self.layout.addWidget(self.node_branchLabel, 0, 0) - self.layout.addWidget(self.rmsLabel, 0, 1) - self.layout.addWidget(self.nodeBranchValue, 1, 0) - self.layout.addWidget(self.rmsValue, 1, 1) - - self.multimeter.setLayout(self.layout) - self.setGeometry(loc_x, loc_y, 200, 100) - self.setGeometry(loc_x, loc_y, 300, 100) - self.setWindowTitle("MultiMeter") - self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) - self.show() - - -class DataExtraction: - def __init__(self): - self.obj_appconfig = Appconfig() - self.data = [] - # consists of all the columns of data belonging to nodes and branches - self.y = [] # stores y-axis data - self.x = [] # stores x-axis data - - def numberFinder(self, fpath): - # Opening Analysis file - with open(os.path.join(fpath, "analysis")) as f3: - self.analysisInfo = f3.read() - self.analysisInfo = self.analysisInfo.split(" ") - - # Reading data file for voltage - with open(os.path.join(fpath, "plot_data_v.txt")) as f2: - self.voltData = f2.read() - - self.voltData = self.voltData.split("\n") - - # Initializing variable - # 'p' gives no. of lines of data for each node/branch - # 'npv' gives the no of partitions for a single voltage node - # 'vnumber' gives total number of voltage - # 'inumber' gives total number of current - - p = npv = vnumber = inumber = 0 - - # Finding totla number of voltage node - for i in self.voltData[3:]: - # it has possible names of voltage nodes in NgSpice - if "Index" in i: # "V(" in i or "x1" in i or "u3" in i: - vnumber += 1 - - # Reading Current Source Data - with open(os.path.join(fpath, "plot_data_i.txt")) as f1: - self.currentData = f1.read() - self.currentData = self.currentData.split("\n") - - # Finding Number of Branch - for i in self.currentData[3:]: - if "#branch" in i: - inumber += 1 - - self.dec = 0 - - # For AC - if self.analysisInfo[0][-3:] == ".ac": - self.analysisType = 0 - if "dec" in self.analysisInfo: - self.dec = 1 - - for i in self.voltData[3:]: - p += 1 # 'p' gives no. of lines of data for each node/branch - if "Index" in i: - npv += 1 - # 'npv' gives the no of partitions for a single voltage node - # print("npv:", npv) - if "AC" in i: # DC for dc files and AC for ac ones - break - - elif ".tran" in self.analysisInfo: - self.analysisType = 1 - for i in self.voltData[3:]: - p += 1 - if "Index" in i: - npv += 1 - # 'npv' gives the no of partitions for a single voltage node - # print("npv:", npv) - if "Transient" in i: # DC for dc files and AC for ac ones - break - - # For DC: - else: - self.analysisType = 2 - for i in self.voltData[3:]: - p += 1 - if "Index" in i: - npv += 1 - # 'npv' gives the no of partitions for a single voltage node - # print("npv:", npv) - if "DC" in i: # DC for dc files and AC for ac ones - break - - vnumber = vnumber // npv # vnumber gives the no of voltage nodes - inumber = inumber // npv # inumber gives the no of branches - - p = [p, vnumber, self.analysisType, self.dec, inumber] - - return p - - def openFile(self, fpath): - try: - with open(os.path.join(fpath, "plot_data_i.txt")) as f2: - alli = f2.read() - - alli = alli.split("\n") - self.NBIList = [] - - with open(os.path.join(fpath, "plot_data_v.txt")) as f1: - allv = f1.read() - - except Exception as e: - print("Exception Message : ", str(e)) - self.obj_appconfig.print_error('Exception Message :' + str(e)) - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage('Unable to open plot data files.') - self.msg.exec_() - - try: - for l in alli[3].split(" "): - if len(l) > 0: - self.NBIList.append(l) - self.NBIList = self.NBIList[2:] - len_NBIList = len(self.NBIList) - except Exception as e: - print("Exception Message : ", str(e)) - self.obj_appconfig.print_error('Exception Message :' + str(e)) - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage('Unable to read Analysis File.') - self.msg.exec_() - - d = self.numberFinder(fpath) - d1 = int(d[0] + 1) - d2 = int(d[1]) - d3 = d[2] - d4 = d[4] - - dec = [d3, d[3]] - self.NBList = [] - allv = allv.split("\n") - for l in allv[3].split(" "): - if len(l) > 0: - self.NBList.append(l) - self.NBList = self.NBList[2:] - len_NBList = len(self.NBList) - print("NBLIST", self.NBList) - - ivals = [] - inum = len(allv[5].split("\t")) - inum_i = len(alli[5].split("\t")) - - full_data = [] - - # Creating list of data: - if d3 < 3: - for i in range(1, d2): - for l in allv[3 + i * d1].split(" "): - if len(l) > 0: - self.NBList.append(l) - self.NBList.pop(len_NBList) - self.NBList.pop(len_NBList) - len_NBList = len(self.NBList) - - for n in range(1, d4): - for l in alli[3 + n * d1].split(" "): - if len(l) > 0: - self.NBIList.append(l) - self.NBIList.pop(len_NBIList) - self.NBIList.pop(len_NBIList) - len_NBIList = len(self.NBIList) - - p = 0 - k = 0 - m = 0 - - for i in alli[5:d1 - 1]: - if len(i.split("\t")) == inum_i: - j2 = i.split("\t") - j2.pop(0) - j2.pop(0) - j2.pop() - if d3 == 0: # not in trans - j2.pop() - - for l in range(1, d4): - j3 = alli[5 + l * d1 + k].split("\t") - j3.pop(0) - j3.pop(0) - if d3 == 0: - j3.pop() # not required for dc - j3.pop() - j2 = j2 + j3 - - full_data.append(j2) - - k += 1 - - for i in allv[5:d1 - 1]: - if len(i.split("\t")) == inum: - j = i.split("\t") - j.pop() - if d3 == 0: - j.pop() - for l in range(1, d2): - j1 = allv[5 + l * d1 + p].split("\t") - j1.pop(0) - j1.pop(0) - if d3 == 0: - j1.pop() # not required for dc - if self.NBList[len(self.NBList) - 1] == 'v-sweep': - self.NBList.pop() - j1.pop() - - j1.pop() - j = j + j1 - j = j + full_data[m] - m += 1 - - j = "\t".join(j[1:]) - j = j.replace(",", "") - ivals.append(j) - - p += 1 - - self.data = ivals - - self.volts_length = len(self.NBList) - self.NBList = self.NBList + self.NBIList - - print(dec) - return dec - - def numVals(self): - a = self.volts_length # No of voltage nodes - b = len(self.data[0].split("\t")) - return [b, a] - - def computeAxes(self): - nums = len(self.data[0].split("\t")) - self.y = [] - var = self.data[0].split("\t") - for i in range(1, nums): - self.y.append([Decimal(var[i])]) - for i in self.data[1:]: - temp = i.split("\t") - for j in range(1, nums): - self.y[j - 1].append(Decimal(temp[j])) - for i in self.data: - temp = i.split("\t") - self.x.append(Decimal(temp[0]))