Source code for pySimBlocks.project.plot_series

# ******************************************************************************
#                                  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
# ******************************************************************************

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np
from matplotlib.lines import Line2D
from matplotlib.markers import MarkerStyle

DEFAULT_SERIES_STYLE: "SeriesStyle"

_SKIP_MARKERS = frozenset({"", " ", "None", "none"})


[docs] @dataclass class SeriesStyle: """Matplotlib draw style for one logged series component.""" color: str = "" linestyle: str = "-" marker: str = "" display_name: str = ""
DEFAULT_SERIES_STYLE = SeriesStyle()
[docs] def normalize_marker_code(marker: object) -> str: """Return a stripped marker code string, or empty for no marker.""" if marker is None: return "" text = str(marker).strip() if not text or text.lower() in _SKIP_MARKERS: return "" return text
[docs] def is_usable_line_marker(marker: object) -> bool: """True if matplotlib can draw this marker on a Line2D (step/plot).""" code = normalize_marker_code(marker) if not code: return False try: Line2D([0], [0], marker=code) return True except (ValueError, TypeError): return False
[docs] def normalize_plot_marker(marker: object) -> str: """Return marker code for plotting, or '' if missing or not supported on lines.""" code = normalize_marker_code(marker) if not code: return "" return code if is_usable_line_marker(code) else ""
[docs] def series_style_to_dict(style: SeriesStyle) -> dict[str, str]: """Serialize a series style, omitting default fields.""" data: dict[str, str] = {} if style.color: data["color"] = style.color if style.linestyle and style.linestyle != "-": data["linestyle"] = style.linestyle marker = normalize_plot_marker(style.marker) if marker: data["marker"] = marker if style.display_name.strip(): data["display_name"] = style.display_name.strip() return data
[docs] def series_style_from_dict(data: dict[str, Any] | None) -> SeriesStyle: """Build a :class:`SeriesStyle` from a YAML/plot-config mapping.""" if not isinstance(data, dict): return SeriesStyle() return SeriesStyle( color=str(data.get("color", "") or ""), linestyle=str(data.get("linestyle", "-") or "-"), marker=str(data.get("marker", "") or ""), display_name=str(data.get("display_name", "") or ""), )
[docs] def merge_series_styles(primary: SeriesStyle, fallback: SeriesStyle) -> SeriesStyle: """Merge two styles; non-empty fields in ``primary`` override ``fallback``.""" return SeriesStyle( color=primary.color or fallback.color, linestyle=primary.linestyle or fallback.linestyle, marker=primary.marker or fallback.marker, display_name=primary.display_name or fallback.display_name, )
[docs] def resolve_series_style( label: str, session_styles: dict[str, SeriesStyle], plot: dict | None = None, ) -> SeriesStyle: """Merge plot-config styles with in-session overrides (session wins).""" from_plot = series_style_from_dict((plot or {}).get("series_styles", {}).get(label)) session = session_styles.get(label) if session is None: return from_plot return merge_series_styles(session, from_plot)
[docs] def effective_style_for_component( plot: dict | None, panel: dict | None, label: str, ) -> SeriesStyle: """Style for one component label: panel ``series_styles`` overrides plot-level.""" plot = plot or {} panel = panel or {} base = series_style_from_dict(plot.get("series_styles", {}).get(label)) override = series_style_from_dict(panel.get("series_styles", {}).get(label)) return merge_series_styles(override, base)
[docs] def filter_series_by_signal( series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]], components: list[str] | None, ) -> list[tuple[str, list[tuple[str, np.ndarray]]]]: """Keep only component labels listed in ``components`` (if provided).""" if not components: return series_by_signal allowed = set(components) filtered: list[tuple[str, list[tuple[str, np.ndarray]]]] = [] for sig, sig_series in series_by_signal: kept = [(label, values) for label, values in sig_series if label in allowed] if kept: filtered.append((sig, kept)) return filtered
[docs] def flatten_series( series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]], ) -> list[tuple[str, str, np.ndarray]]: """Flatten grouped series into (signal, label, values) tuples.""" return [ (sig, label, values) for sig, sig_series in series_by_signal for label, values in sig_series ]
[docs] 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)
[docs] 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)]
[docs] def plot_step_series_styled( ax, time: np.ndarray, values: np.ndarray, label: str, style: SeriesStyle | None = None, ) -> None: """Draw one step series with optional style (shared by GUI and plot_from_config).""" st = style or DEFAULT_SERIES_STYLE legend = st.display_name.strip() if st.display_name.strip() else label kwargs: dict = {"where": "post", "label": legend} if st.color: kwargs["color"] = st.color if st.linestyle: kwargs["linestyle"] = st.linestyle marker = normalize_plot_marker(st.marker) if marker: kwargs["marker"] = marker ax.step(time, values, **kwargs)
MANUAL_LAYOUT_KEY = "manual"
[docs] def is_manual_layout_plot(plot: dict) -> bool: """Return True if ``plot`` is a saved multi-panel manual layout preset.""" return str(plot.get("layout", "")).strip().lower() == MANUAL_LAYOUT_KEY
[docs] def panel_dict_from_selection( title: str, selection: dict[str, set[str]], series_styles: dict[str, SeriesStyle] | None = None, ) -> dict[str, Any]: """Serialize one manual panel for YAML storage.""" out: dict[str, Any] = { "title": title.strip() or "Plot", "selection": {sig: sorted(labels) for sig, labels in selection.items()}, } if not series_styles: return out serialized: dict[str, dict[str, str]] = {} for lbl, st in series_styles.items(): data = series_style_to_dict(st) if data: serialized[str(lbl)] = data if serialized: out["series_styles"] = serialized return out
[docs] def selection_from_panel_dict(panel: dict) -> dict[str, set[str]]: """Restore one manual panel selection from YAML.""" raw = panel.get("selection") if isinstance(raw, dict): return {str(sig): {str(lbl) for lbl in labels} for sig, labels in raw.items() if labels} signals = panel.get("signals", []) components = panel.get("components", []) if not isinstance(signals, list) or not isinstance(components, list): return {} selection: dict[str, set[str]] = {str(sig): set() for sig in signals} for comp in components: comp_s = str(comp) for sig in selection: if comp_s == sig or comp_s.startswith(f"{sig}["): selection[sig].add(comp_s) break return {sig: labels for sig, labels in selection.items() if labels}
[docs] def manual_layout_to_plot_dict( preset_title: str, panel_titles: list[str], panel_selections: list[dict[str, set[str]]], panel_styles: list[dict[str, SeriesStyle]], ) -> dict[str, Any] | None: """Build one plot preset that stores a full manual multi-panel layout. Each entry in ``panel_styles`` holds styles for component labels in that panel only (name, color, etc. may differ per panel for the same signal). """ panels: list[dict[str, Any]] = [] for i, (title, selection) in enumerate(zip(panel_titles, panel_selections)): if not selection: continue ps = panel_styles[i] if i < len(panel_styles) else {} picked: dict[str, SeriesStyle] = {} for labels in selection.values(): for lbl in labels: sl = str(lbl) if sl in ps: picked[sl] = ps[sl] panels.append(panel_dict_from_selection(title, selection, picked)) if not panels: return None return { "title": preset_title.strip() or "Manual layout", "layout": MANUAL_LAYOUT_KEY, "panels": panels, }
[docs] def manual_state_from_layout_plot( plot: dict, ) -> tuple[list[dict[str, set[str]]], list[str], list[dict[str, SeriesStyle]]]: """Extract manual panel state from a layout preset. Returns one style map per panel (merged plot-level + panel-level YAML). """ panels_raw = plot.get("panels", []) selections: list[dict[str, set[str]]] = [] titles: list[str] = [] per_panel_styles: list[dict[str, SeriesStyle]] = [] if isinstance(panels_raw, list): for i, panel in enumerate(panels_raw): if not isinstance(panel, dict): continue titles.append(str(panel.get("title", f"Plot {i + 1}"))) sel = selection_from_panel_dict(panel) selections.append(sel) merged: dict[str, SeriesStyle] = {} for labels in sel.values(): for lbl in labels: sl = str(lbl) merged[sl] = effective_style_for_component(plot, panel, sl) per_panel_styles.append(merged) return selections, titles, per_panel_styles