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]))