Source code for pySimBlocks.gui.dialogs.plot_dialog

# ******************************************************************************
#                                  pySimBlocks
#                     Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
#  This program is free software: you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or (at your
#  option) any later version.
#
#  This program is distributed in the hope that it will be useful, but WITHOUT
#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
#  for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
#  Authors: see Authors.txt
# ******************************************************************************

import numpy as np
import matplotlib.pyplot as plt

from PySide6.QtWidgets import (
    QDialog, QHBoxLayout, QVBoxLayout,
    QLabel, QListWidget, QListWidgetItem,
    QPushButton, QSizePolicy, QMessageBox
)
from PySide6.QtCore import Qt

from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure

from pySimBlocks.gui.models.project_state import ProjectState
from pySimBlocks.core.config import PlotConfig
from pySimBlocks.project.plot_from_config import plot_from_config


[docs] class PlotDialog(QDialog): """Preview logged signals and launch configured plot windows. Attributes: project_state: Project state providing logs and plot definitions. selected_signals: Signals currently selected for preview. """ def __init__(self, project_state: ProjectState, parent=None): """Initialize the plot dialog. Args: project_state: Project state providing logs and plot definitions. parent: Optional parent widget. Raises: None. """ super().__init__(parent) self.setWindowTitle("Plot signals") self.resize(900, 500) self.project_state = project_state self.selected_signals: set[str] = set() self._build_ui() self._populate_signals() # -------------------------------------------------------------------------- # Private Methods # -------------------------------------------------------------------------- def _build_ui(self): """Build the plot dialog user interface.""" main_layout = QHBoxLayout(self) # ---------- Left panel ---------- left_layout = QVBoxLayout() title = QLabel("<b>Signals (logged)</b>") left_layout.addWidget(title) self.signal_list = QListWidget() self.signal_list.setSelectionMode(QListWidget.NoSelection) self.signal_list.itemChanged.connect(self._on_signal_toggled) left_layout.addWidget(self.signal_list) self.plot_defined_btn = QPushButton("Plot defined plots") self.plot_defined_btn.clicked.connect(self._plot_defined_plots) left_layout.addWidget(self.plot_defined_btn) main_layout.addLayout(left_layout, 0) # ---------- Plot preview ---------- self.figure = Figure() self.canvas = FigureCanvasQTAgg(self.figure) self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) main_layout.addWidget(self.canvas, 1) def _populate_signals(self): """Populate the signal list from the available logged signals.""" self.signal_list.clear() for sig in sorted(self.project_state.logs.keys()): if sig == "time": continue item = QListWidgetItem(sig) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Unchecked) self.signal_list.addItem(item) def _on_signal_toggled(self, item: QListWidgetItem): """Update the selected signal set when a checkbox changes.""" sig = item.text() if item.checkState() == Qt.Checked: self.selected_signals.add(sig) else: self.selected_signals.discard(sig) self._update_preview_plot() def _stack_logged_signal_2d(self, sig: str) -> np.ndarray: """Stack a logged signal over time while preserving its 2D shape. Args: sig: Signal name to stack from the logs. Returns: Array of shape ``(T, m, n)`` containing the stacked samples. Raises: ValueError: If the signal is missing, contains ``None``, or its samples are not consistent 2D arrays. """ samples = self.project_state.logs.get(sig, None) if not isinstance(samples, list) or len(samples) == 0: raise ValueError(f"Signal '{sig}' has no samples in logs.") # Find first non-None sample to define shape first = None for s in samples: if s is not None: first = np.asarray(s) break if first is None: raise ValueError(f"Signal '{sig}' is always None; cannot plot.") if first.ndim != 2: raise ValueError(f"Signal '{sig}' must be 2D. Got ndim={first.ndim} with shape {first.shape}.") shape0 = first.shape stacked = [] for k, s in enumerate(samples): if s is None: raise ValueError(f"Signal '{sig}' contains None at index {k}; cannot plot.") a = np.asarray(s) if a.ndim != 2: raise ValueError( f"Signal '{sig}' sample {k} must be 2D. Got ndim={a.ndim} with shape {a.shape}." ) if a.shape != shape0: raise ValueError( f"Signal '{sig}' shape changed over time: expected {shape0}, got {a.shape} at sample {k}." ) stacked.append(a) return np.stack(stacked, axis=0) # (T, m, n) def _update_preview_plot(self): """Redraw the embedded preview plot from the selected signals.""" self.figure.clear() ax = self.figure.add_subplot(111) if not self.selected_signals: self.canvas.draw() return time = np.asarray(self.project_state.logs["time"]).flatten() T = len(time) try: for sig in sorted(self.selected_signals): data = self._stack_logged_signal_2d(sig) # (T, m, n) if data.shape[0] != T: raise ValueError( f"Time length mismatch for '{sig}': time has {T} samples but signal has {data.shape[0]}." ) m, n = data.shape[1], data.shape[2] # scalar if (m, n) == (1, 1): ax.step(time, data[:, 0, 0], where="post", label=sig) continue # vector column (m,1) if n == 1: for i in range(m): ax.step(time, data[:, i, 0], where="post", label=f"{sig}[{i}]") continue # matrix (m,n) for r in range(m): for c in range(n): ax.step(time, data[:, r, c], where="post", label=f"{sig}[{r},{c}]") ax.set_xlabel("Time [s]") ax.grid(True) ax.legend() except Exception as e: # Keep the UI responsive; show the error inside the plot area. ax.text( 0.01, 0.99, f"Plot preview error:\n{e}", transform=ax.transAxes, va="top", ha="left", wrap=True, ) ax.set_axis_off() self.canvas.draw() def _plot_defined_plots(self): """Open standalone matplotlib windows for the configured plots.""" if not self.project_state.plots: QMessageBox.information( self, "No plots defined", "No plots are defined in the project settings." ) return plot_from_config( logs=self.project_state.logs, plot_cfg=PlotConfig(self.project_state.plots), show=True, block=False )