Source code for pySimBlocks.project.plot_from_config

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

import numpy as np
import matplotlib.pyplot as plt

from pySimBlocks.core.config import PlotConfig


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


[docs] def plot_from_config( logs: dict, plot_cfg: PlotConfig | None, show: bool = True, block: bool = True, ) -> None: """Plot logged simulation signals according to a :class:`PlotConfig`. Each signal component is plotted as a separate step curve. Labels follow the convention: scalar signals use ``sig``; column vector elements use ``sig[i]``; matrix elements use ``sig[r,c]``. Args: logs: Dictionary of logged signals as returned by :meth:`~Simulator.run`. Must contain a ``'time'`` key. plot_cfg: Plot configuration. If None, the function returns immediately without producing any figures. show: If True, call ``plt.show()`` after creating all figures. block: Passed to ``plt.show()``; controls whether the call blocks. Raises: KeyError: If any signal requested by ``plot_cfg`` was not logged, or if ``'time'`` is missing from ``logs``. ValueError: If any logged signal is not a list of 2D arrays with a consistent shape. """ if plot_cfg is None: return requested_signals = set() for plot in plot_cfg.plots: requested_signals.update(plot["signals"]) available_signals = set(logs.keys()) available_signals.discard("time") missing = sorted(requested_signals - available_signals) if missing: raise KeyError( "The following signals are requested for plotting but were not logged:\n" + "\n".join(f" - {sig}" for sig in missing) + "\n\nAvailable logged signals:\n" + "\n".join(f" - {sig}" for sig in sorted(available_signals)) ) if "time" not in logs: raise KeyError("Logs must contain a 'time' entry.") time = np.asarray(logs["time"]).flatten() T = len(time) if T == 0: return for plot in plot_cfg.plots: title = plot.get("title", "") signals = plot["signals"] plt.figure() for sig in signals: data = _stack_logged_signal(logs, sig) 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] if (m, n) == (1, 1): plt.step(time, data[:, 0, 0], where="post", label=sig) continue if n == 1: for i in range(m): plt.step(time, data[:, i, 0], where="post", label=f"{sig}[{i}]") continue for r in range(m): for c in range(n): plt.step(time, data[:, r, c], where="post", label=f"{sig}[{r},{c}]") plt.xlabel("Time [s]") plt.grid(True) plt.legend() if title: plt.title(title) if show: plt.show(block=block)