diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index 62b0c8b..f2adcc4 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -23,6 +23,7 @@ import numpy as np import matplotlib.pyplot as plt +from dataclasses import dataclass, field from PySide6.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem, @@ -40,9 +41,8 @@ from pySimBlocks.core.config import PlotConfig from pySimBlocks.project.plot_from_config import plot_from_config from pySimBlocks.gui.dialogs.plot_series_style_dialog import SeriesStyleDialog -from pySimBlocks.gui.plot_series_draw import plot_step_series from pySimBlocks.gui.project_controller import ProjectController -from pySimBlocks.project.plot_series_helpers import ( +from pySimBlocks.project.plot_series import ( DEFAULT_SERIES_STYLE, SeriesStyle, effective_style_for_component, @@ -50,12 +50,126 @@ is_manual_layout_plot, manual_layout_to_plot_dict, manual_state_from_layout_plot, + plot_step_series_styled, resolve_series_style, + series_from_signal, series_style_from_dict, series_style_to_dict, ) +@dataclass +class _ManualLayoutSession: + """Holds all mutable state for the multi-panel manual plot editor.""" + + selections: list[dict[str, set[str]]] = field(default_factory=lambda: [{}]) + titles: list[str] = field(default_factory=lambda: ["Plot 1"]) + active: int = 0 + panel_styles: list[dict[str, SeriesStyle]] = field(default_factory=lambda: [{}]) + + @staticmethod + def default_title(index: int) -> str: + return f"Plot {index + 1}" + + def ensure_titles_aligned(self) -> None: + while len(self.titles) < len(self.selections): + self.titles.append(self.default_title(len(self.titles))) + if len(self.titles) > len(self.selections): + self.titles = self.titles[: len(self.selections)] + + def plot_title(self, index: int) -> str: + self.ensure_titles_aligned() + if 0 <= index < len(self.titles): + text = self.titles[index].strip() + if text: + return text + return self.default_title(index) + + def save_title(self, index: int, text: str) -> None: + self.ensure_titles_aligned() + idx = min(index, len(self.titles) - 1) + self.titles[idx] = text + + def clamp_active(self) -> None: + if not self.selections: + self.active = 0 + return + self.active = min(self.active, len(self.selections) - 1) + + def resize(self, value: int) -> None: + old_count = len(self.selections) + if value > old_count: + for i in range(old_count, value): + self.selections.append({}) + self.titles.append(self.default_title(i)) + self.panel_styles.append({}) + elif value < old_count: + self.selections = self.selections[:value] + self.titles = self.titles[:value] + self.panel_styles = self.panel_styles[:value] + if self.active >= value: + self.active = max(0, value - 1) + + def remove_at(self, idx: int) -> None: + del self.selections[idx] + del self.titles[idx] + del self.panel_styles[idx] + new_count = len(self.selections) + if self.active >= new_count: + self.active = max(0, new_count - 1) + + def load_from_preset(self, plot: dict) -> None: + selections, titles, panel_styles = manual_state_from_layout_plot(plot) + if not selections: + self.selections = [{}] + self.titles = [self.default_title(0)] + self.panel_styles = [{}] + else: + self.selections = selections + self.titles = titles + self.panel_styles = panel_styles + while len(self.panel_styles) < len(self.selections): + self.panel_styles.append({}) + self.panel_styles = self.panel_styles[: len(self.selections)] + self.active = 0 + + +@dataclass +class _ModePresetSession: + """Holds mutable state for mode-based preset editing (styles + working cache).""" + + styles: dict[str, SeriesStyle] = field(default_factory=dict) + working: dict[int, dict] = field(default_factory=dict, init=False, repr=False) + + def store(self, idx: int, signals: list[str], mode: str) -> None: + self.working[idx] = { + "signals": signals, + "mode": mode, + "styles": copy.deepcopy(self.styles), + } + + def get_cached(self, idx: int) -> dict | None: + return self.working.get(idx) + + def serialize_styles(self) -> dict[str, dict[str, str]]: + out: dict[str, dict[str, str]] = {} + for lbl, st in self.styles.items(): + data = series_style_to_dict(st) + if data: + out[str(lbl)] = data + return out + + @staticmethod + def styles_from_plot(plot: dict) -> dict[str, SeriesStyle]: + styles: dict[str, SeriesStyle] = {} + raw = plot.get("series_styles", {}) + if isinstance(raw, dict): + for lbl, cfg in raw.items(): + if isinstance(cfg, dict): + styles[str(lbl)] = series_style_from_dict(cfg) + return styles + + class PlotDialog(QDialog): """Preview logged signals and launch configured plot windows. @@ -100,18 +214,14 @@ def __init__( self._updating_signal_tree = False self._focused_panel_key: str | None = None self._axis_to_panel_key: dict[int, str] = {} - self._manual_plot_selections: list[dict[str, set[str]]] = [{}] - self._manual_plot_titles: list[str] = ["Plot 1"] - self._manual_active_plot = 0 - self._global_session_styles: dict[str, SeriesStyle] = {} - self._panel_series_styles: list[dict[str, SeriesStyle]] = [{}] - self._mode_preset_working: dict[int, dict[str, object]] = {} + self._manual = _ManualLayoutSession() + self._mode = _ModePresetSession() self._last_preset_index: int | None = None self._build_ui() self._populate_signals() self._populate_plot_presets() - self._clamp_manual_active_plot() + self._manual.clamp_active() self._load_active_manual_title() self._sync_manual_controls_enabled() @@ -121,10 +231,10 @@ def present(self) -> None: self._populate_signals() self._populate_plot_presets() if self._uses_manual_layout(): - self._clamp_manual_active_plot() + self._manual.clamp_active() self._load_active_manual_title() self._load_manual_selection_to_tree( - self._manual_plot_selections[self._manual_active_plot] + self._manual.selections[self._manual.active] ) self._refresh_all_tree_style_labels() elif self._is_mode_preset(): @@ -149,8 +259,11 @@ def _disable_enter_key_activation(button: QPushButton) -> None: def _build_ui(self): """Build the plot dialog user interface.""" main_layout = QHBoxLayout(self) + main_layout.addLayout(self._build_left_panel(), 0) + main_layout.addLayout(self._build_right_panel(), 1) - # ---------- Left panel ---------- + def _build_left_panel(self) -> QVBoxLayout: + """Build and return the left control panel.""" left_layout = QVBoxLayout() left_layout.addWidget(QLabel("Manual plots")) @@ -196,8 +309,7 @@ def _build_ui(self): self.save_preset_btn.clicked.connect(self._save_plot_preset) left_layout.addWidget(self.save_preset_btn) - title = QLabel("Signals (logged)") - left_layout.addWidget(title) + left_layout.addWidget(QLabel("Signals (logged)")) self.signal_tree = QTreeWidget() self.signal_tree.setColumnCount(2) @@ -249,9 +361,10 @@ def _build_ui(self): self.plot_defined_btn.clicked.connect(self._plot_defined_plots) left_layout.addWidget(self.plot_defined_btn) - main_layout.addLayout(left_layout, 0) + return left_layout - # ---------- Plot preview ---------- + def _build_right_panel(self) -> QVBoxLayout: + """Build and return the right plot preview panel.""" self.figure = Figure() self.canvas = FigureCanvasQTAgg(self.figure) self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -277,7 +390,7 @@ def _build_ui(self): right_layout.addLayout(controls_layout) right_layout.addWidget(self.nav_toolbar) right_layout.addWidget(self.canvas, 1) - main_layout.addLayout(right_layout, 1) + return right_layout def _populate_signals(self): """Populate the signal list from the available logged signals.""" @@ -321,12 +434,12 @@ def _add_scalar_signal_tree_row(self, sig: str, label: str) -> None: def _get_series_style(self, label: str, plot: dict | None = None) -> SeriesStyle: """Return merged style for a series (session overrides, then plot config).""" - return resolve_series_style(label, self._global_session_styles, plot) + return resolve_series_style(label, self._mode.styles, plot) def _get_manual_series_style(self, panel_idx: int, label: str) -> SeriesStyle: """Style for one component in a manual-layout panel (per subplot).""" - if 0 <= panel_idx < len(self._panel_series_styles): - st = self._panel_series_styles[panel_idx].get(label) + if 0 <= panel_idx < len(self._manual.panel_styles): + st = self._manual.panel_styles[panel_idx].get(label) if st is not None: return st if self._is_manual_layout_preset(): @@ -339,7 +452,7 @@ def _get_manual_series_style(self, panel_idx: int, label: str) -> SeriesStyle: def _tree_label_for_component(self, internal_label: str) -> str: """Text shown in the signal tree for one component.""" if self._uses_manual_layout(): - name = self._get_manual_series_style(self._manual_active_plot, internal_label).display_name.strip() + name = self._get_manual_series_style(self._manual.active, internal_label).display_name.strip() else: plot = None idx = self._selected_preset_index() @@ -395,10 +508,10 @@ def _attach_style_button(self, item: QTreeWidgetItem, label: str) -> None: def _edit_series_style(self, label: str) -> None: """Open the style dialog for one series component.""" if self._uses_manual_layout(): - pidx = self._manual_active_plot - while len(self._panel_series_styles) <= pidx: - self._panel_series_styles.append({}) - current = self._panel_series_styles[pidx].get(label, SeriesStyle()) + pidx = self._manual.active + while len(self._manual.panel_styles) <= pidx: + self._manual.panel_styles.append({}) + current = self._manual.panel_styles[pidx].get(label, SeriesStyle()) else: plot = None idx = self._selected_preset_index() @@ -411,10 +524,10 @@ def _edit_series_style(self, label: str) -> None: if dlg.exec() != QDialog.DialogCode.Accepted: return if self._uses_manual_layout(): - pidx = self._manual_active_plot - self._panel_series_styles[pidx][label] = dlg.style() + pidx = self._manual.active + self._manual.panel_styles[pidx][label] = dlg.style() else: - self._global_session_styles[label] = dlg.style() + self._mode.styles[label] = dlg.style() if self._is_mode_preset(): idx = self._selected_preset_index() if idx is not None: @@ -481,7 +594,7 @@ def _on_plot_preset_changed(self, _index: int): if idx is not None: self._load_mode_preset(idx) elif free_manual: - self._load_manual_selection_to_tree(self._manual_plot_selections[self._manual_active_plot]) + self._load_manual_selection_to_tree(self._manual.selections[self._manual.active]) self._last_preset_index = self._selected_preset_index() self._update_preview_plot() @@ -521,44 +634,21 @@ def _sync_manual_controls_enabled(self) -> None: ) self.save_preset_btn.setEnabled(can_save) - @staticmethod - def _default_manual_plot_title(index: int) -> str: - return f"Plot {index + 1}" - - def _ensure_manual_titles(self) -> None: - """Keep title list aligned with manual plot count.""" - while len(self._manual_plot_titles) < len(self._manual_plot_selections): - i = len(self._manual_plot_titles) - self._manual_plot_titles.append(self._default_manual_plot_title(i)) - if len(self._manual_plot_titles) > len(self._manual_plot_selections): - self._manual_plot_titles = self._manual_plot_titles[: len(self._manual_plot_selections)] - - def _manual_plot_title(self, index: int) -> str: - """Return the axis title for one manual plot panel.""" - self._ensure_manual_titles() - if 0 <= index < len(self._manual_plot_titles): - text = self._manual_plot_titles[index].strip() - if text: - return text - return self._default_manual_plot_title(index) - def _save_active_manual_title(self) -> None: """Persist the title field into the active manual plot.""" - if not self._manual_plot_selections: + if not self._manual.selections: return - self._ensure_manual_titles() - idx = min(self._manual_active_plot, len(self._manual_plot_titles) - 1) - self._manual_plot_titles[idx] = self.manual_plot_title_edit.text().strip() + self._manual.save_title(self._manual.active, self.manual_plot_title_edit.text().strip()) def _load_active_manual_title(self) -> None: """Load the active manual plot title into the title field.""" - if not self._manual_plot_selections: + if not self._manual.selections: self.manual_plot_title_edit.clear() return - self._ensure_manual_titles() - idx = min(self._manual_active_plot, len(self._manual_plot_titles) - 1) + self._manual.ensure_titles_aligned() + idx = min(self._manual.active, len(self._manual.titles) - 1) self.manual_plot_title_edit.blockSignals(True) - self.manual_plot_title_edit.setText(self._manual_plot_titles[idx]) + self.manual_plot_title_edit.setText(self._manual.titles[idx]) self.manual_plot_title_edit.blockSignals(False) def _on_manual_plot_title_edited(self) -> None: @@ -566,81 +656,56 @@ def _on_manual_plot_title_edited(self) -> None: self._save_active_manual_title() self._update_preview_plot() - def _clamp_manual_active_plot(self) -> None: - """Keep the active manual plot index within range.""" - if not self._manual_plot_selections: - self._manual_active_plot = 0 - return - self._manual_active_plot = min( - self._manual_active_plot, len(self._manual_plot_selections) - 1 - ) - def _on_manual_plot_count_changed(self, value: int) -> None: """Grow or shrink manual plots; decreasing removes only the last plot.""" self._save_active_manual_selection() self._save_active_manual_title() - old_count = len(self._manual_plot_selections) - if value > old_count: - for i in range(old_count, value): - self._manual_plot_selections.append({}) - self._manual_plot_titles.append(self._default_manual_plot_title(i)) - self._panel_series_styles.append({}) - elif value < old_count: - self._manual_plot_selections = self._manual_plot_selections[:value] - self._manual_plot_titles = self._manual_plot_titles[:value] - self._panel_series_styles = self._panel_series_styles[:value] - if self._manual_active_plot >= value: - self._manual_active_plot = max(0, value - 1) - self._clamp_manual_active_plot() + self._manual.resize(value) + self._manual.clamp_active() self._sync_manual_controls_enabled() self._load_active_manual_title() - self._load_manual_selection_to_tree(self._manual_plot_selections[self._manual_active_plot]) + self._load_manual_selection_to_tree(self._manual.selections[self._manual.active]) self._refresh_all_tree_style_labels() self._update_preview_plot() def _on_manual_remove_selected_plot(self) -> None: """Remove the plot currently selected for editing.""" - if len(self._manual_plot_selections) <= 1: + if len(self._manual.selections) <= 1: return self._save_active_manual_title() - idx = self._manual_active_plot - del self._manual_plot_selections[idx] - del self._manual_plot_titles[idx] - del self._panel_series_styles[idx] - new_count = len(self._manual_plot_selections) + self._manual.remove_at(self._manual.active) + new_count = len(self._manual.selections) self.manual_plot_count_spin.blockSignals(True) self.manual_plot_count_spin.setValue(new_count) self.manual_plot_count_spin.blockSignals(False) - if self._manual_active_plot >= new_count: - self._manual_active_plot = new_count - 1 - self._clamp_manual_active_plot() + self._manual.clamp_active() self._sync_manual_controls_enabled() self._load_active_manual_title() - self._load_manual_selection_to_tree(self._manual_plot_selections[self._manual_active_plot]) + self._load_manual_selection_to_tree(self._manual.selections[self._manual.active]) self._refresh_all_tree_style_labels() self._update_preview_plot() def _select_manual_plot(self, index: int) -> None: """Select a manual plot for editing (e.g. after clicking its axis).""" - if index < 0 or index >= len(self._manual_plot_selections): + if index < 0 or index >= len(self._manual.selections): return - if index == self._manual_active_plot: + if index == self._manual.active: self._update_preview_plot() return self._save_active_manual_selection() self._save_active_manual_title() - self._manual_active_plot = index + self._manual.active = index self._load_active_manual_title() - self._load_manual_selection_to_tree(self._manual_plot_selections[index]) + self._load_manual_selection_to_tree(self._manual.selections[index]) self._update_preview_plot() def _save_active_manual_selection(self) -> None: """Persist the signal tree checks into the active manual plot.""" - if not self._manual_plot_selections: + if not self._manual.selections: return - idx = min(self._manual_active_plot, len(self._manual_plot_selections) - 1) - self._manual_active_plot = idx - self._manual_plot_selections[idx] = self._read_selection_from_signal_tree() + idx = min(self._manual.active, len(self._manual.selections) - 1) + self._manual.active = idx + self._manual.selections[idx] = self._read_selection_from_signal_tree() def _read_selection_from_signal_tree(self) -> dict[str, set[str]]: """Return selected component labels per signal from the tree widget.""" @@ -798,63 +863,7 @@ def _find_manual_preset_index_by_title(self, title: str) -> int | None: def _component_labels_for_signal(self, sig: str) -> list[str]: """Return component labels for one signal.""" - data = self._stack_logged_signal_2d(sig) - m, n = data.shape[1], data.shape[2] - if (m, n) == (1, 1): - return [sig] - if n == 1: - return [f"{sig}[{i}]" for i in range(m)] - return [f"{sig}[{r},{c}]" for r in range(m) for c in range(n)] - - 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) - + return [label for label, _ in series_from_signal(self.project_state.logs, sig)] def _update_preview_plot(self): """Redraw the embedded preview plot from the selected signals.""" @@ -865,9 +874,9 @@ def _update_preview_plot(self): sel = self._read_selection_from_signal_tree() # Avoid wiping panel data when the tree was rebuilt but not restored yet. if sel or not ( - self._manual_plot_selections - and self._manual_plot_selections[ - min(self._manual_active_plot, len(self._manual_plot_selections) - 1) + self._manual.selections + and self._manual.selections[ + min(self._manual.active, len(self._manual.selections) - 1) ] ): self._save_active_manual_selection() @@ -889,33 +898,12 @@ def _update_preview_plot(self): try: series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]] = [] for sig in active_signals: - data = self._stack_logged_signal_2d(sig) # (T, m, n) - - if data.shape[0] != T: + sig_series = series_from_signal(self.project_state.logs, sig) + n_samples = len(sig_series[0][1]) if sig_series else 0 + if n_samples != T: raise ValueError( - f"Time length mismatch for '{sig}': time has {T} samples but signal has {data.shape[0]}." + f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}." ) - - m, n = data.shape[1], data.shape[2] - - # scalar - if (m, n) == (1, 1): - series_by_signal.append((sig, [(sig, data[:, 0, 0])])) - continue - - # vector column (m,1) - if n == 1: - sig_series = [] - for i in range(m): - sig_series.append((f"{sig}[{i}]", data[:, i, 0])) - series_by_signal.append((sig, sig_series)) - continue - - # matrix (m,n) - sig_series = [] - for r in range(m): - for c in range(n): - sig_series.append((f"{sig}[{r},{c}]", data[:, r, c])) series_by_signal.append((sig, sig_series)) flat_series = flatten_series(series_by_signal) @@ -953,22 +941,14 @@ def _series_from_manual_selection( T = len(time) series: list[tuple[str, np.ndarray]] = [] for sig in sorted(selection.keys()): - data = self._stack_logged_signal_2d(sig) - if data.shape[0] != T: + sig_series = series_from_signal(self.project_state.logs, sig) + n_samples = len(sig_series[0][1]) if sig_series else 0 + if n_samples != T: raise ValueError( - f"Time length mismatch for '{sig}': time has {T} samples but signal has {data.shape[0]}." + f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}." ) - m, n = data.shape[1], data.shape[2] - if (m, n) == (1, 1): - candidates = [(sig, data[:, 0, 0])] - elif n == 1: - candidates = [(f"{sig}[{i}]", data[:, i, 0]) for i in range(m)] - else: - candidates = [ - (f"{sig}[{r},{c}]", data[:, r, c]) for r in range(m) for c in range(n) - ] selected_labels = selection[sig] - for label, values in candidates: + for label, values in sig_series: if label in selected_labels: series.append((label, values)) return series @@ -985,7 +965,7 @@ def _draw_manual_panel( """Draw one manual plot panel and register it for hit-testing.""" if series: for label, values in series: - plot_step_series( + plot_step_series_styled( ax, time, values, @@ -1004,23 +984,23 @@ def _draw_manual_panel( ax.set_title(title) self._style_axis(ax) self._axis_to_panel_key[id(ax)] = key - self._highlight_manual_axis(ax, key == f"manual::{self._manual_active_plot}") + self._highlight_manual_axis(ax, key == f"manual::{self._manual.active}") def _render_manual_plots_preview(self) -> None: """Render all manual plot panels in a 2-column grid (last odd spans full width).""" self._refresh_subplot_filter([], keep_current=False) self._axis_to_panel_key.clear() - n_plots = len(self._manual_plot_selections) + n_plots = len(self._manual.selections) if n_plots == 0: return time = np.asarray(self.project_state.logs["time"]).flatten() panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]] = [] try: - for i, selection in enumerate(self._manual_plot_selections): + for i, selection in enumerate(self._manual.selections): series = self._series_from_manual_selection(selection, time) - title = self._manual_plot_title(i) + title = self._manual.plot_title(i) panels.append((title, f"manual::{i}", series)) except Exception as e: ax = self.figure.add_subplot(111) @@ -1176,25 +1156,13 @@ def _build_panels_for_mode( def _load_manual_state_from_layout_plot(self, plot: dict) -> None: """Restore manual panels, titles, and styles from a layout preset.""" - selections, titles, panel_styles = manual_state_from_layout_plot(plot) - if not selections: - self._manual_plot_selections = [{}] - self._manual_plot_titles = [self._default_manual_plot_title(0)] - self._panel_series_styles = [{}] - else: - self._manual_plot_selections = selections - self._manual_plot_titles = titles - self._panel_series_styles = panel_styles - while len(self._panel_series_styles) < len(self._manual_plot_selections): - self._panel_series_styles.append({}) - self._panel_series_styles = self._panel_series_styles[: len(self._manual_plot_selections)] - self._manual_active_plot = 0 + self._manual.load_from_preset(plot) self.manual_plot_count_spin.blockSignals(True) - self.manual_plot_count_spin.setValue(len(self._manual_plot_selections)) + self.manual_plot_count_spin.setValue(len(self._manual.selections)) self.manual_plot_count_spin.blockSignals(False) - self._clamp_manual_active_plot() + self._manual.clamp_active() self._load_active_manual_title() - self._load_manual_selection_to_tree(self._manual_plot_selections[self._manual_active_plot]) + self._load_manual_selection_to_tree(self._manual.selections[self._manual.active]) def _flush_active_preset_working(self) -> None: """Persist in-memory edits for the active preset before rebuilding the UI.""" @@ -1274,46 +1242,22 @@ def _load_mode_signals_to_tree(self, signals: set[str]) -> None: finally: self._updating_signal_tree = False - def _styles_dict_from_plot(self, plot: dict) -> dict[str, SeriesStyle]: - styles: dict[str, SeriesStyle] = {} - raw = plot.get("series_styles", {}) - if isinstance(raw, dict): - for lbl, cfg in raw.items(): - if isinstance(cfg, dict): - styles[str(lbl)] = series_style_from_dict(cfg) - return styles - - def _serialize_session_styles(self) -> dict[str, dict[str, str]]: - out: dict[str, dict[str, str]] = {} - for lbl, st in self._global_session_styles.items(): - data = series_style_to_dict(st) - if data: - out[str(lbl)] = data - return out - def _store_mode_preset_working(self, idx: int) -> None: - self._mode_preset_working[idx] = { - "signals": self._read_mode_signals_from_tree(), - "mode": self._current_mode_preset_mode(), - "styles": copy.deepcopy(self._global_session_styles), - } + self._mode.store(idx, self._read_mode_signals_from_tree(), self._current_mode_preset_mode()) def _load_mode_preset(self, idx: int) -> None: """Restore a mode preset from session cache or project YAML.""" - cached = self._mode_preset_working.get(idx) + cached = self._mode.get_cached(idx) if isinstance(cached, dict): signals = [str(s) for s in cached.get("signals", []) if s] mode = str(cached.get("mode", "auto")) raw_styles = cached.get("styles", {}) - if isinstance(raw_styles, dict): - self._global_session_styles = copy.deepcopy(raw_styles) - else: - self._global_session_styles = {} + self._mode.styles = copy.deepcopy(raw_styles) if isinstance(raw_styles, dict) else {} else: plot = self.project_state.plots[idx] signals = [str(s) for s in plot.get("signals", []) if s] mode = str(plot.get("mode", "auto")) - self._global_session_styles = self._styles_dict_from_plot(plot) + self._mode.styles = _ModePresetSession.styles_from_plot(plot) self._set_mode_preset_combo(mode) self._load_mode_signals_to_tree(set(signals)) @@ -1332,7 +1276,8 @@ def _save_mode_as_plot_preset(self) -> None: if idx is None or self.project_controller is None: return self._store_mode_preset_working(idx) - signals = list(self._mode_preset_working[idx].get("signals", [])) + cached = self._mode.get_cached(idx) or {} + signals = list(cached.get("signals", [])) if not signals: QMessageBox.warning( self, @@ -1340,14 +1285,14 @@ def _save_mode_as_plot_preset(self) -> None: "No signal selected.\nCheck at least one signal.", ) return - mode = str(self._mode_preset_working[idx].get("mode", "auto")) + mode = str(cached.get("mode", "auto")) title = str(self.project_state.plots[idx].get("title", "Plot")) self.project_controller.update_plot( idx, title, signals, mode=mode, - series_styles=self._serialize_session_styles(), + series_styles=self._mode.serialize_styles(), ) QMessageBox.information( self, @@ -1363,7 +1308,7 @@ def _save_manual_as_plot_preset(self) -> None: self._save_active_manual_selection() self._save_active_manual_title() - self._ensure_manual_titles() + self._manual.ensure_titles_aligned() editing_idx = ( self._selected_preset_index() if self._is_manual_layout_preset() else None @@ -1374,7 +1319,7 @@ def _save_manual_as_plot_preset(self) -> None: ).strip() or "Manual layout" else: default_name = ( - self._manual_plot_title(0) if self._manual_plot_titles else "Manual layout" + self._manual.plot_title(0) if self._manual.titles else "Manual layout" ) name, ok = QInputDialog.getText( self, @@ -1387,9 +1332,9 @@ def _save_manual_as_plot_preset(self) -> None: preset_name = name.strip() plot_dict = manual_layout_to_plot_dict( preset_name, - self._manual_plot_titles, - self._manual_plot_selections, - self._panel_series_styles, + self._manual.titles, + self._manual.selections, + self._manual.panel_styles, ) if plot_dict is None: QMessageBox.warning( @@ -1459,7 +1404,7 @@ def _render_panels( ax = self.figure.add_subplot(111) title, key, series = panels[0] for label, values in series: - plot_step_series(ax, time, values, label, self._get_series_style(label, plot)) + plot_step_series_styled(ax, time, values, label, self._get_series_style(label, plot)) ax.set_title(title) self._style_axis(ax) self._axis_to_panel_key[id(ax)] = key @@ -1472,7 +1417,7 @@ def _render_panels( for i, (title, key, series) in enumerate(panels): ax = axes[i] for label, values in series: - plot_step_series(ax, time, values, label, self._get_series_style(label, plot)) + plot_step_series_styled(ax, time, values, label, self._get_series_style(label, plot)) ax.set_title(title) self._style_axis(ax) self._axis_to_panel_key[id(ax)] = key @@ -1488,7 +1433,6 @@ def _style_axis(self, ax) -> None: if self.legend_cb.isChecked() and ax.lines: ax.legend() - def _finalize_layout(self) -> None: """Apply a robust layout for many stacked axes.""" n_axes = len(self.figure.axes) diff --git a/pySimBlocks/gui/dialogs/plot_series_style_dialog.py b/pySimBlocks/gui/dialogs/plot_series_style_dialog.py index e00d9e6..dc5388e 100644 --- a/pySimBlocks/gui/dialogs/plot_series_style_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_series_style_dialog.py @@ -40,7 +40,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor -from pySimBlocks.project.plot_series_helpers import ( +from pySimBlocks.project.plot_series import ( DEFAULT_SERIES_STYLE, SeriesStyle, is_usable_line_marker, @@ -48,8 +48,6 @@ normalize_plot_marker, ) -_SKIP_MARKERS = frozenset({"", " ", "None", "none"}) - def marker_display_label(marker: str) -> str: """Human-readable marker name for the combo box (matplotlib registry description).""" diff --git a/pySimBlocks/gui/dialogs/settings/plots.py b/pySimBlocks/gui/dialogs/settings/plots.py index a06443b..ce4c657 100644 --- a/pySimBlocks/gui/dialogs/settings/plots.py +++ b/pySimBlocks/gui/dialogs/settings/plots.py @@ -26,7 +26,7 @@ from pySimBlocks.gui.models.project_state import ProjectState from pySimBlocks.gui.project_controller import ProjectController -from pySimBlocks.project.plot_series_helpers import is_manual_layout_plot +from pySimBlocks.project.plot_series import is_manual_layout_plot class PlotSettingsWidget(QWidget): diff --git a/pySimBlocks/gui/plot_series_draw.py b/pySimBlocks/gui/plot_series_draw.py deleted file mode 100644 index 8987917..0000000 --- a/pySimBlocks/gui/plot_series_draw.py +++ /dev/null @@ -1,36 +0,0 @@ -# ****************************************************************************** -# 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 . -# ****************************************************************************** -# Authors: see Authors.txt -# ****************************************************************************** - -from __future__ import annotations - -import numpy as np - -from pySimBlocks.project.plot_series_helpers import SeriesStyle, plot_step_series_styled - - -def plot_step_series( - ax, - time: np.ndarray, - values: np.ndarray, - label: str, - style: SeriesStyle | None = None, -) -> None: - """Draw one step series on ``ax`` using optional line/marker/color style.""" - plot_step_series_styled(ax, time, values, label, style) diff --git a/pySimBlocks/project/plot_from_config.py b/pySimBlocks/project/plot_from_config.py index 0c0a9a4..01c4ca9 100644 --- a/pySimBlocks/project/plot_from_config.py +++ b/pySimBlocks/project/plot_from_config.py @@ -25,68 +25,17 @@ from matplotlib import gridspec from pySimBlocks.core.config import PlotConfig -from pySimBlocks.project.plot_series_helpers import ( +from pySimBlocks.project.plot_series import ( effective_style_for_component, flatten_series, is_manual_layout_plot, plot_step_series_styled, resolve_series_style, selection_from_panel_dict, + series_from_signal, ) -def _stack_logged_signal(logs: dict, sig: str) -> np.ndarray: - """Stack a logged signal over time into a (T, m, n) array.""" - samples = logs[sig] - if not isinstance(samples, list) or len(samples) == 0: - raise ValueError(f"Signal '{sig}' has no samples in logs.") - - 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) - - data = np.stack(stacked, axis=0) - return data - - -def _series_from_signal(logs: dict, sig: str) -> list[tuple[str, np.ndarray]]: - """Return flat (label, values) series for one signal.""" - data = _stack_logged_signal(logs, sig) - m, n = data.shape[1], data.shape[2] - - if (m, n) == (1, 1): - return [(sig, data[:, 0, 0])] - - if n == 1: - return [(f"{sig}[{i}]", data[:, i, 0]) for i in range(m)] - - return [(f"{sig}[{r},{c}]", data[:, r, c]) for r in range(m) for c in range(n)] - def _resolve_plot_mode(plot: dict, total_series: int, signal_count: int) -> str: """Resolve plot mode with safe defaults.""" @@ -123,22 +72,14 @@ def _series_from_manual_panel( T = len(time) series: list[tuple[str, np.ndarray]] = [] for sig in sorted(selection.keys()): - data = _stack_logged_signal(logs, sig) - if data.shape[0] != T: + sig_series = series_from_signal(logs, sig) + n_samples = len(sig_series[0][1]) if sig_series else 0 + if n_samples != T: raise ValueError( - f"Time length mismatch for '{sig}': time has {T} samples but signal has {data.shape[0]}." + f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}." ) - m, n = data.shape[1], data.shape[2] - if (m, n) == (1, 1): - candidates = [(sig, data[:, 0, 0])] - elif n == 1: - candidates = [(f"{sig}[{i}]", data[:, i, 0]) for i in range(m)] - else: - candidates = [ - (f"{sig}[{r},{c}]", data[:, r, c]) for r in range(m) for c in range(n) - ] selected_labels = selection[sig] - for label, values in candidates: + for label, values in sig_series: if label in selected_labels: series.append((label, values)) return series @@ -282,12 +223,13 @@ def plot_from_config( series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]] = [] for sig in signals: - data = _stack_logged_signal(logs, sig) - if data.shape[0] != T: + sig_series = series_from_signal(logs, sig) + n_samples = len(sig_series[0][1]) if sig_series else 0 + if n_samples != T: raise ValueError( - f"Time length mismatch for '{sig}': time has {T} samples but signal has {data.shape[0]}." + f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}." ) - series_by_signal.append((sig, _series_from_signal(logs, sig))) + series_by_signal.append((sig, sig_series)) flat_series = flatten_series(series_by_signal) mode = _resolve_plot_mode(plot, total_series=len(flat_series), signal_count=len(signals)) diff --git a/pySimBlocks/project/plot_series_helpers.py b/pySimBlocks/project/plot_series.py similarity index 85% rename from pySimBlocks/project/plot_series_helpers.py rename to pySimBlocks/project/plot_series.py index 0322439..0b9d92b 100644 --- a/pySimBlocks/project/plot_series_helpers.py +++ b/pySimBlocks/project/plot_series.py @@ -165,6 +165,49 @@ def flatten_series( ] +def stack_logged_signal(logs: dict, sig: str) -> np.ndarray: + """Stack a logged signal over time into a ``(T, m, n)`` array.""" + samples = logs.get(sig) + if not isinstance(samples, list) or len(samples) == 0: + raise ValueError(f"Signal '{sig}' has no samples in logs.") + 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) + + +def series_from_signal(logs: dict, sig: str) -> list[tuple[str, np.ndarray]]: + """Return flat (label, values) series for one signal.""" + data = stack_logged_signal(logs, sig) + m, n = data.shape[1], data.shape[2] + if (m, n) == (1, 1): + return [(sig, data[:, 0, 0])] + if n == 1: + return [(f"{sig}[{i}]", data[:, i, 0]) for i in range(m)] + return [(f"{sig}[{r},{c}]", data[:, r, c]) for r in range(m) for c in range(n)] + + def plot_step_series_styled( ax, time: np.ndarray,