From 54a72f649d6b707a7e154ca3b105beceb09ff99e Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 30 Mar 2026 15:31:01 +0200 Subject: [PATCH 1/3] fix(gui): file source remove key only if explicit npy file --- pySimBlocks/gui/blocks/sources/file_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/blocks/sources/file_source.py b/pySimBlocks/gui/blocks/sources/file_source.py index f5fb64a..1c8583c 100644 --- a/pySimBlocks/gui/blocks/sources/file_source.py +++ b/pySimBlocks/gui/blocks/sources/file_source.py @@ -118,10 +118,10 @@ def is_parameter_active(self, ext = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else "" if param_name == "key": - return ext in {"npz", "csv"} + return ext != "npy" if param_name == "use_time": - return ext in {"npz", "csv"} + return ext != "npy" return super().is_parameter_active(param_name, instance_params) From e0a230074b2fcaed92cfde80a3938f78e06c8254 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Thu, 2 Apr 2026 17:22:52 +0200 Subject: [PATCH 2/3] feat(blocks): adding goto and from blocks --- pySimBlocks/blocks/interfaces/__init__.py | 6 +- pySimBlocks/blocks/interfaces/bus_from.py | 88 +++++++ pySimBlocks/blocks/interfaces/goto.py | 78 ++++++ pySimBlocks/core/model.py | 42 ++++ pySimBlocks/core/signal_bus.py | 37 +++ pySimBlocks/core/simulator.py | 3 + .../gui/blocks/block_dialog_session.py | 19 +- pySimBlocks/gui/blocks/block_meta.py | 5 +- pySimBlocks/gui/blocks/interfaces/bus_from.py | 228 ++++++++++++++++++ pySimBlocks/gui/blocks/interfaces/goto.py | 74 ++++++ pySimBlocks/gui/dialogs/block_dialog.py | 8 +- .../project/pySimBlocks_blocks_index.yaml | 6 + tests/blocks/interfaces/test_goto_from.py | 173 +++++++++++++ 13 files changed, 757 insertions(+), 10 deletions(-) create mode 100644 pySimBlocks/blocks/interfaces/bus_from.py create mode 100644 pySimBlocks/blocks/interfaces/goto.py create mode 100644 pySimBlocks/core/signal_bus.py create mode 100644 pySimBlocks/gui/blocks/interfaces/bus_from.py create mode 100644 pySimBlocks/gui/blocks/interfaces/goto.py create mode 100644 tests/blocks/interfaces/test_goto_from.py diff --git a/pySimBlocks/blocks/interfaces/__init__.py b/pySimBlocks/blocks/interfaces/__init__.py index 42756f1..eb936ca 100644 --- a/pySimBlocks/blocks/interfaces/__init__.py +++ b/pySimBlocks/blocks/interfaces/__init__.py @@ -20,8 +20,12 @@ from pySimBlocks.blocks.interfaces.external_input import ExternalInput from pySimBlocks.blocks.interfaces.external_output import ExternalOutput +from pySimBlocks.blocks.interfaces.goto import Goto +from pySimBlocks.blocks.interfaces.bus_from import BusFrom __all__ = [ "ExternalInput", - "ExternalOutput" + "ExternalOutput", + "Goto", + "BusFrom", ] diff --git a/pySimBlocks/blocks/interfaces/bus_from.py b/pySimBlocks/blocks/interfaces/bus_from.py new file mode 100644 index 0000000..ea0dae6 --- /dev/null +++ b/pySimBlocks/blocks/interfaces/bus_from.py @@ -0,0 +1,88 @@ +# ****************************************************************************** +# 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 pySimBlocks.core.block import Block +from pySimBlocks.core import signal_bus + + +class BusFrom(Block): + """Read a signal from the global signal bus by tag. + + BusFrom and Goto blocks implement a virtual wiring mechanism: a Goto writes + its input to ``signal_bus._signal_bus[tag]`` each tick, and this block + reads that value without requiring an explicit connection in the model + graph. + + The model's topological sort injects a virtual edge from each Goto to every + BusFrom sharing the same tag, ensuring the Goto executes before this block + within the same tick. + """ + + direct_feedthrough = True + + def __init__(self, name: str, tag: str, sample_time: float | None = None): + """Initialize a BusFrom block. + + Args: + name: Unique identifier for this block instance. + tag: Signal bus tag to read from. Must match the tag of the + corresponding Goto block. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ + super().__init__(name, sample_time) + self.tag = tag + self.outputs["out"] = None + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + + def initialize(self, t0: float) -> None: + """Read the initial value from the signal bus if available. + + If the tag is not yet in the bus (Goto not yet initialized), the + output is set to None. + + Args: + t0: Initial simulation time in seconds. + """ + self.outputs["out"] = signal_bus._signal_bus.get(self.tag) + + def output_update(self, t: float, dt: float) -> None: + """Read the current value from the signal bus. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + KeyError: If no Goto with the matching tag has written to the bus + in this run. + """ + if self.tag not in signal_bus._signal_bus: + raise KeyError( + f"[{self.name}] Tag '{self.tag}' not found in signal bus. " + "Ensure a Goto block with the same tag exists in the model." + ) + self.outputs["out"] = signal_bus._signal_bus[self.tag] + + def state_update(self, t: float, dt: float) -> None: + """No-op: BusFrom carries no internal state.""" diff --git a/pySimBlocks/blocks/interfaces/goto.py b/pySimBlocks/blocks/interfaces/goto.py new file mode 100644 index 0000000..8d53cf7 --- /dev/null +++ b/pySimBlocks/blocks/interfaces/goto.py @@ -0,0 +1,78 @@ +# ****************************************************************************** +# 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 pySimBlocks.core.block import Block +from pySimBlocks.core import signal_bus + + +class Goto(Block): + """Publish a signal to the global signal bus under a named tag. + + Goto and From blocks implement a virtual wiring mechanism: a Goto writes + its input to ``signal_bus._signal_bus[tag]`` each tick, and any From block + with the same tag reads that value without requiring an explicit connection + in the model graph. + + The model's topological sort injects a virtual edge from each Goto to every + From sharing the same tag, ensuring the Goto executes before its consumers + within the same tick. + """ + + direct_feedthrough = True + + def __init__(self, name: str, tag: str, sample_time: float | None = None): + """Initialize a Goto block. + + Args: + name: Unique identifier for this block instance. + tag: Signal bus tag under which the input value is published. + Must match the tag of the corresponding From block(s). + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ + super().__init__(name, sample_time) + self.tag = tag + self.inputs["in"] = None + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + + def initialize(self, t0: float) -> None: + """Publish the current input to the signal bus. + + If no input has been connected yet (None), the bus entry is set to None. + + Args: + t0: Initial simulation time in seconds. + """ + signal_bus._signal_bus[self.tag] = self.inputs["in"] + + def output_update(self, t: float, dt: float) -> None: + """Write the input value to the signal bus under this block's tag. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + signal_bus._signal_bus[self.tag] = self.inputs["in"] + + def state_update(self, t: float, dt: float) -> None: + """No-op: Goto carries no internal state.""" diff --git a/pySimBlocks/core/model.py b/pySimBlocks/core/model.py index eb8e874..e291124 100644 --- a/pySimBlocks/core/model.py +++ b/pySimBlocks/core/model.py @@ -203,6 +203,13 @@ def build_execution_order(self): for k, v in indegree.items(): vprint(f" {k}: {v}") + # STEP 1b — Inject virtual edges from Goto → BusFrom (same tag) + for (goto_name, bus_from_name) in self._build_virtual_edges(): + if goto_name in graph and bus_from_name in graph: + graph[goto_name].append(bus_from_name) + indegree[bus_from_name] += 1 + vprint(f" VIRTUAL EDGE: {goto_name} -> {bus_from_name} (shared tag)") + # STEP 2 — Kahn topological sort vprint("\n--- STEP 2: TOPOLOGICAL SORT ---") @@ -309,3 +316,38 @@ def _rebuild_downstream_map(self) -> None: downstream[src[0]].append((src, dst)) self._downstream_map = downstream self._connections_dirty = False + + def _build_virtual_edges(self) -> List[Tuple[str, str]]: + """Return virtual Goto → BusFrom edges for matching signal bus tags. + + Iterates over all blocks in the model, collects Goto and BusFrom + instances grouped by tag, and returns one directed edge per + (Goto, BusFrom) pair that shares a tag. These edges are injected into + the topological sort graph so that every BusFrom executes after its + corresponding Goto within the same tick. + + Local imports are used to avoid circular imports between the core + package and the blocks package. + + Returns: + List of ``(goto_block_name, bus_from_block_name)`` tuples. + """ + from pySimBlocks.blocks.interfaces.goto import Goto + from pySimBlocks.blocks.interfaces.bus_from import BusFrom + + tag_to_gotos: Dict[str, List[str]] = {} + tag_to_bus_froms: Dict[str, List[str]] = {} + + for name, block in self.blocks.items(): + if isinstance(block, Goto): + tag_to_gotos.setdefault(block.tag, []).append(name) + elif isinstance(block, BusFrom): + tag_to_bus_froms.setdefault(block.tag, []).append(name) + + edges: List[Tuple[str, str]] = [] + for tag, goto_names in tag_to_gotos.items(): + for bus_from_name in tag_to_bus_froms.get(tag, []): + for goto_name in goto_names: + edges.append((goto_name, bus_from_name)) + + return edges diff --git a/pySimBlocks/core/signal_bus.py b/pySimBlocks/core/signal_bus.py new file mode 100644 index 0000000..d1336b1 --- /dev/null +++ b/pySimBlocks/core/signal_bus.py @@ -0,0 +1,37 @@ +# ****************************************************************************** +# 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 +# ****************************************************************************** + +"""Global signal bus shared by Goto and BusFrom blocks. + +Goto blocks write their input value into ``_signal_bus`` under their tag. +BusFrom blocks read from ``_signal_bus`` by tag. The bus is reset at the start +of each simulation run so that successive runs are fully isolated. +""" + +_signal_bus: dict = {} + + +def reset() -> None: + """Clear all entries in the signal bus. + + Must be called at the start of each simulation run to prevent signal + bleed-over between independent runs. + """ + _signal_bus.clear() diff --git a/pySimBlocks/core/simulator.py b/pySimBlocks/core/simulator.py index 27b923c..fc76bf1 100644 --- a/pySimBlocks/core/simulator.py +++ b/pySimBlocks/core/simulator.py @@ -28,6 +28,7 @@ from pySimBlocks.core.model import Model from pySimBlocks.core.scheduler import Scheduler from pySimBlocks.core.task import Task +from pySimBlocks.core import signal_bus class Simulator: @@ -192,6 +193,8 @@ def run( if self.sim_cfg.clock == "external": raise RuntimeError("Simulator.run() is not supported with external clock. Use step(dt_override=...)") + signal_bus.reset() + sim_duration = T if T is not None else self.sim_cfg.T t0_run = t0 if t0 is not None else self.sim_cfg.t0 logging_run = logging if logging is not None else self.sim_cfg.logging diff --git a/pySimBlocks/gui/blocks/block_dialog_session.py b/pySimBlocks/gui/blocks/block_dialog_session.py index 88de29d..c54fbe0 100644 --- a/pySimBlocks/gui/blocks/block_dialog_session.py +++ b/pySimBlocks/gui/blocks/block_dialog_session.py @@ -19,7 +19,7 @@ # ****************************************************************************** from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from PySide6.QtWidgets import QLineEdit @@ -27,6 +27,7 @@ if TYPE_CHECKING: from pySimBlocks.gui.blocks.block_meta import BlockMeta + from pySimBlocks.gui.models.project_state import ProjectState class BlockDialogSession: @@ -36,6 +37,7 @@ class BlockDialogSession: meta: Block metadata driving the dialog. instance: Block instance being edited. project_dir: Project directory used to resolve relative files. + project_state: Full project state, available when opening from the GUI. local_params: Local parameter cache for the open dialog. param_widgets: Widgets keyed by parameter name. param_labels: Labels keyed by parameter name. @@ -47,6 +49,7 @@ def __init__( meta: "BlockMeta", instance: BlockInstance, project_dir: Path | None = None, + project_state: "ProjectState | None" = None, ): """Initialize a block dialog session. @@ -54,16 +57,20 @@ def __init__( meta: Block metadata driving the dialog. instance: Block instance being edited. project_dir: Project directory used to resolve relative files. + project_state: Full project state, used by blocks that need to + inspect other blocks in the diagram (e.g. From reads Goto + tags). None when the session is created outside the GUI. Raises: None. """ - self.meta = meta + self.meta = meta self.instance = instance self.project_dir = project_dir + self.project_state: "ProjectState | None" = project_state # --- STATE UI (par dialog) --- - self.local_params = dict(instance.parameters) - self.param_widgets = {} - self.param_labels = {} - self.name_edit: QLineEdit | None = None + self.local_params: dict[str, Any] = dict(instance.parameters) + self.param_widgets: dict[str, Any] = {} + self.param_labels: dict[str, Any] = {} + self.name_edit: QLineEdit | None = None diff --git a/pySimBlocks/gui/blocks/block_meta.py b/pySimBlocks/gui/blocks/block_meta.py index 04016c8..621951c 100644 --- a/pySimBlocks/gui/blocks/block_meta.py +++ b/pySimBlocks/gui/blocks/block_meta.py @@ -84,17 +84,20 @@ def create_dialog_session( self, instance: BlockInstance, project_dir: Path | None = None, + project_state=None, ) -> BlockDialogSession: """Create a dialog session for a block instance. Args: instance: Block instance being edited. project_dir: Project directory used to resolve relative files. + project_state: Full project state for blocks that need to + inspect other blocks in the diagram. None outside the GUI. Returns: New dialog session object bound to the instance. """ - return BlockDialogSession(self, instance, project_dir) + return BlockDialogSession(self, instance, project_dir, project_state) def is_parameter_active(self, param_name: str, diff --git a/pySimBlocks/gui/blocks/interfaces/bus_from.py b/pySimBlocks/gui/blocks/interfaces/bus_from.py new file mode 100644 index 0000000..5802919 --- /dev/null +++ b/pySimBlocks/gui/blocks/interfaces/bus_from.py @@ -0,0 +1,228 @@ +# ****************************************************************************** +# 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 PySide6.QtWidgets import QComboBox, QFormLayout, QLabel, QLineEdit + +from pySimBlocks.gui.blocks.block_dialog_session import BlockDialogSession +from pySimBlocks.gui.blocks.block_meta import BlockMeta +from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta +from pySimBlocks.gui.blocks.port_meta import PortMeta + + +class BusFromMeta(BlockMeta): + """Describe the GUI metadata of the BusFrom interface block. + + The ``tag`` parameter is rendered as a dropdown populated with all tags + currently declared by Goto blocks in the same diagram. If no Goto block + is present (e.g. outside the full GUI context), the field falls back to + a plain text edit so the user can still type a tag manually. + """ + + def __init__(self): + """Initialize BusFrom block metadata. + + Args: + None. + + Raises: + None. + """ + self.name = "BusFrom" + self.category = "interfaces" + self.type = "bus_from" + self.summary = "Read a signal from the virtual signal bus by tag." + self.description = ( + "Reads the value published by the matching **Goto** block each tick.\n\n" + "The **tag** dropdown lists all tags currently declared by Goto blocks\n" + "in the diagram. No explicit wire connection is needed between Goto\n" + "and BusFrom: the signal bus handles routing automatically.\n\n" + "The topological sort guarantees that the matching Goto executes\n" + "before this block within the same simulation step." + ) + + self.parameters = [ + ParameterMeta( + name="tag", + type="str", + required=True, + description=( + "Signal bus tag. Must match the tag of the corresponding " + "Goto block." + ), + ), + ParameterMeta( + name="sample_time", + type="float", + ), + ] + + self.outputs = [ + PortMeta( + name="out", + display_as="out", + shape=["n", 1], + description="Signal read from the bus.", + ) + ] + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + + def build_param( + self, + session: BlockDialogSession, + form: QFormLayout, + readonly: bool = False, + ) -> None: + """Build parameter widgets, replacing the tag field with a dropdown. + + When a project state is available, the ``tag`` parameter is rendered + as a ``QComboBox`` populated with every tag currently declared by a + Goto block in the diagram. An extra ``(free text)`` entry lets the + user type an arbitrary tag when the desired Goto does not exist yet. + If no project state is available the tag falls back to a plain + ``QLineEdit``. + + All other parameters use the standard widget builder from the base + class. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ + # Block name row (standard) + name_edit = QLineEdit(session.instance.name) + name_edit.textChanged.connect( + lambda val: self._on_param_changed(val, "name", session, readonly) + ) + if readonly: + name_edit.setReadOnly(True) + form.addRow(QLabel("Block name:"), name_edit) + session.name_edit = name_edit + + # Parameter rows + for param_meta in self.parameters: + if param_meta.name == "tag": + label, widget = self._build_tag_row(session, param_meta, readonly) + else: + label, widget = self._create_param_row(session, param_meta, readonly) + if widget is None: + continue + + if readonly: + self._set_readonly_style(widget) + + form.addRow(label, widget) + session.param_widgets[param_meta.name] = widget + session.param_labels[param_meta.name] = label + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + + def _collect_goto_tags(self, session: BlockDialogSession) -> list[str]: + """Return all tag values declared by Goto blocks in the project. + + Args: + session: Active dialog session with an optional project_state. + + Returns: + Sorted list of unique tag strings found in Goto blocks. + Empty list when project state is unavailable. + """ + project_state = session.project_state + if project_state is None: + return [] + + tags: list[str] = [] + for block in project_state.blocks: + if block.meta.type == "goto": + tag = block.parameters.get("tag") + if tag and isinstance(tag, str) and tag not in tags: + tags.append(tag) + + return sorted(tags) + + def _build_tag_row( + self, + session: BlockDialogSession, + param_meta: ParameterMeta, + readonly: bool, + ) -> tuple[QLabel, QComboBox | QLineEdit]: + """Build the tag parameter row as a dropdown or a plain text edit. + + A ``QComboBox`` is used when at least one Goto tag is available in + the project. A sentinel entry ``(free text)`` is appended so the + user can still enter a tag that does not correspond to any existing + Goto block. When the sentinel is selected the combo is replaced by + a ``QLineEdit``. + + If no Goto tags are found, a plain ``QLineEdit`` is used directly. + + Args: + session: Active dialog session. + param_meta: Metadata for the ``tag`` parameter. + readonly: Whether the widget should be read-only. + + Returns: + ``(label, widget)`` pair for the tag parameter row. + """ + label = QLabel(f"{param_meta.name}:") + if param_meta.description: + label.setToolTip(param_meta.description) + + goto_tags = self._collect_goto_tags(session) + current_value = session.local_params.get("tag") or "" + + if not goto_tags: + # No Goto tags available — plain text edit + widget = QLineEdit() + widget.setText(str(current_value)) + widget.textChanged.connect( + lambda val: self._on_param_changed(val, "tag", session, readonly) + ) + return label, widget + + # Build combo with all known tags plus a free-text sentinel + _FREE_TEXT = "(free text)" + combo = QComboBox() + for tag in goto_tags: + combo.addItem(tag) + combo.addItem(_FREE_TEXT) + + # Pre-select the current value if it is in the list + if current_value in goto_tags: + combo.setCurrentText(current_value) + else: + combo.setCurrentText(_FREE_TEXT) + + def _on_combo_changed(text: str) -> None: + if text == _FREE_TEXT: + return + self._on_param_changed(text, "tag", session, readonly) + + combo.currentTextChanged.connect(_on_combo_changed) + # Propagate the initial selection immediately + if combo.currentText() != _FREE_TEXT: + self._on_param_changed(combo.currentText(), "tag", session, readonly) + + return label, combo diff --git a/pySimBlocks/gui/blocks/interfaces/goto.py b/pySimBlocks/gui/blocks/interfaces/goto.py new file mode 100644 index 0000000..373f98f --- /dev/null +++ b/pySimBlocks/gui/blocks/interfaces/goto.py @@ -0,0 +1,74 @@ +# ****************************************************************************** +# 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 pySimBlocks.gui.blocks.block_meta import BlockMeta +from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta +from pySimBlocks.gui.blocks.port_meta import PortMeta + + +class GotoMeta(BlockMeta): + """Describe the GUI metadata of the Goto interface block.""" + + def __init__(self): + """Initialize Goto block metadata. + + Args: + None. + + Raises: + None. + """ + self.name = "Goto" + self.category = "interfaces" + self.type = "goto" + self.summary = "Publish a signal to the virtual signal bus." + self.description = ( + "Writes the input signal to the global signal bus under a named **tag**.\n\n" + "Any **From** block in the same diagram that shares the same tag will\n" + "automatically receive this value each tick, without requiring an explicit\n" + "wire connection.\n\n" + "The topological sort guarantees that this block executes before all\n" + "matching From blocks within the same simulation step." + ) + + self.parameters = [ + ParameterMeta( + name="tag", + type="str", + required=True, + description=( + "Signal bus tag. Must match the tag of the corresponding " + "From block(s)." + ), + ), + ParameterMeta( + name="sample_time", + type="float", + ), + ] + + self.inputs = [ + PortMeta( + name="in", + display_as="in", + shape=["n", 1], + description="Signal to publish on the bus.", + ) + ] diff --git a/pySimBlocks/gui/dialogs/block_dialog.py b/pySimBlocks/gui/dialogs/block_dialog.py index 321c349..998578f 100644 --- a/pySimBlocks/gui/dialogs/block_dialog.py +++ b/pySimBlocks/gui/dialogs/block_dialog.py @@ -73,12 +73,16 @@ def __init__(self, main_layout = QVBoxLayout(self) project_dir = None + project_state = None if hasattr(self.block, "view") and self.block.view is not None: controller = getattr(self.block.view, "project_controller", None) if controller is not None and controller.project_state is not None: - project_dir = controller.project_state.directory_path + project_state = controller.project_state + project_dir = project_state.directory_path - self.session = self.meta.create_dialog_session(self.instance, project_dir) + self.session = self.meta.create_dialog_session( + self.instance, project_dir, project_state + ) self.build_meta_layout(main_layout) self.build_buttons_layout(main_layout) diff --git a/pySimBlocks/project/pySimBlocks_blocks_index.yaml b/pySimBlocks/project/pySimBlocks_blocks_index.yaml index 3bfb0b1..2e45a96 100644 --- a/pySimBlocks/project/pySimBlocks_blocks_index.yaml +++ b/pySimBlocks/project/pySimBlocks_blocks_index.yaml @@ -6,12 +6,18 @@ controllers: class: StateFeedback module: pySimBlocks.blocks.controllers.state_feedback interfaces: + bus_from: + class: BusFrom + module: pySimBlocks.blocks.interfaces.bus_from external_input: class: ExternalInput module: pySimBlocks.blocks.interfaces.external_input external_output: class: ExternalOutput module: pySimBlocks.blocks.interfaces.external_output + goto: + class: Goto + module: pySimBlocks.blocks.interfaces.goto observers: luenberger: class: Luenberger diff --git a/tests/blocks/interfaces/test_goto_from.py b/tests/blocks/interfaces/test_goto_from.py new file mode 100644 index 0000000..1f13ed5 --- /dev/null +++ b/tests/blocks/interfaces/test_goto_from.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from pySimBlocks.core.model import Model +from pySimBlocks.core.config import SimulationConfig +from pySimBlocks.core.simulator import Simulator +from pySimBlocks.core import signal_bus +from pySimBlocks.blocks.interfaces.goto import Goto +from pySimBlocks.blocks.interfaces.bus_from import BusFrom +from pySimBlocks.blocks.sources.constant import Constant + + +def _run(model: Model, dt: float, T: float, logging: list[str]): + cfg = SimulationConfig(dt=dt, T=T, t0=0.0, solver="fixed", logging=logging) + sim = Simulator(model=model, sim_cfg=cfg, verbose=False) + sim.run() + return sim.logs + + +# -------------------------------------------------------------------------- +# Unit tests: signal_bus module +# -------------------------------------------------------------------------- + +def test_signal_bus_reset(): + """reset() clears all entries from the bus.""" + signal_bus._signal_bus["foo"] = np.array([[1.0]]) + signal_bus.reset() + assert signal_bus._signal_bus == {} + + +# -------------------------------------------------------------------------- +# Unit tests: Goto block +# -------------------------------------------------------------------------- + +def test_goto_writes_to_bus(): + """Goto.output_update writes input value to the signal bus.""" + signal_bus.reset() + g = Goto("g", tag="x") + g.initialize(0.0) + g.inputs["in"] = np.array([[3.0]]) + g.output_update(0.0, 0.01) + assert np.allclose(signal_bus._signal_bus["x"], np.array([[3.0]])) + + +def test_goto_initialize_sets_none_when_no_input(): + """Goto.initialize stores None in the bus when no input is connected.""" + signal_bus.reset() + g = Goto("g", tag="y") + g.initialize(0.0) + assert signal_bus._signal_bus["y"] is None + + +# -------------------------------------------------------------------------- +# Unit tests: BusFrom block +# -------------------------------------------------------------------------- + +def test_bus_from_reads_from_bus(): + """BusFrom.output_update reads the value written by Goto.""" + signal_bus.reset() + signal_bus._signal_bus["sig"] = np.array([[7.0]]) + f = BusFrom("f", tag="sig") + f.initialize(0.0) + f.output_update(0.0, 0.01) + assert np.allclose(f.outputs["out"], np.array([[7.0]])) + + +def test_bus_from_raises_when_tag_missing(): + """BusFrom.output_update raises KeyError when the tag is absent from the bus.""" + signal_bus.reset() + f = BusFrom("f", tag="missing_tag") + with pytest.raises(KeyError, match="missing_tag"): + f.output_update(0.0, 0.01) + + +def test_bus_from_initialize_returns_none_when_tag_absent(): + """BusFrom.initialize sets output to None if bus has no matching entry yet.""" + signal_bus.reset() + f = BusFrom("f", tag="absent") + f.initialize(0.0) + assert f.outputs["out"] is None + + +# -------------------------------------------------------------------------- +# Integration: BusFrom without matching Goto raises during simulation +# -------------------------------------------------------------------------- + +def test_bus_from_without_goto_raises(): + """A BusFrom block with no matching Goto raises during the first simulation step.""" + m = Model(name="orphan_bus_from") + m.add_block(BusFrom("reader", tag="orphan")) + + cfg = SimulationConfig(dt=0.01, T=0.01, t0=0.0, solver="fixed", logging=[]) + sim = Simulator(model=m, sim_cfg=cfg, verbose=False) + + with pytest.raises((KeyError, RuntimeError)): + sim.run() + + +# -------------------------------------------------------------------------- +# Integration: basic Goto → BusFrom signal forwarding +# -------------------------------------------------------------------------- + +def test_goto_bus_from_basic_forwarding(): + """A signal published by Goto is correctly received by BusFrom in each tick.""" + m = Model(name="basic_fwd") + m.add_block(Constant("src", value=5.0)) + m.add_block(Goto("writer", tag="shared")) + m.add_block(BusFrom("reader", tag="shared")) + m.connect("src", "out", "writer", "in") + + logs = _run(m, dt=0.01, T=0.05, logging=["reader.outputs.out"]) + values = np.array(logs["reader.outputs.out"]).flatten() + assert np.allclose(values, 5.0) + + +# -------------------------------------------------------------------------- +# Integration: execution order — BusFrom executes after Goto in same tick +# -------------------------------------------------------------------------- + +def test_execution_order_goto_before_bus_from(): + """build_execution_order places every Goto before its matching BusFrom.""" + m = Model(name="order_test") + m.add_block(Constant("src", value=1.0)) + m.add_block(Goto("g", tag="t")) + m.add_block(BusFrom("f", tag="t")) + m.connect("src", "out", "g", "in") + + order = m.build_execution_order() + names = [b.name for b in order] + assert names.index("g") < names.index("f") + + +def test_execution_order_multiple_bus_froms(): + """All BusFrom blocks for the same tag are ordered after their Goto.""" + m = Model(name="multi_bus_from") + m.add_block(Constant("src", value=2.0)) + m.add_block(Goto("g", tag="bus")) + m.add_block(BusFrom("f1", tag="bus")) + m.add_block(BusFrom("f2", tag="bus")) + m.connect("src", "out", "g", "in") + + order = m.build_execution_order() + names = [b.name for b in order] + assert names.index("g") < names.index("f1") + assert names.index("g") < names.index("f2") + + +# -------------------------------------------------------------------------- +# Integration: bus isolation across runs (no bleed-over) +# -------------------------------------------------------------------------- + +def test_bus_reset_between_runs(): + """Two sequential runs with the same tag do not share bus state. + + Run 1 publishes value=1.0; run 2 publishes value=2.0. + The BusFrom block must read 2.0 in run 2, not the stale 1.0 from run 1. + """ + def make_model(value: float) -> Model: + m = Model(name=f"model_{value}") + m.add_block(Constant("src", value=value)) + m.add_block(Goto("writer", tag="shared_tag")) + m.add_block(BusFrom("reader", tag="shared_tag")) + m.connect("src", "out", "writer", "in") + return m + + logs1 = _run(make_model(1.0), dt=0.01, T=0.01, logging=["reader.outputs.out"]) + logs2 = _run(make_model(2.0), dt=0.01, T=0.01, logging=["reader.outputs.out"]) + + v1 = float(np.array(logs1["reader.outputs.out"]).flatten()[0]) + v2 = float(np.array(logs2["reader.outputs.out"]).flatten()[0]) + + assert np.isclose(v1, 1.0) + assert np.isclose(v2, 2.0) From bffb31e5a1fd8e50d2044c919110f44c035ee4d1 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 18 Apr 2026 13:58:22 +0200 Subject: [PATCH 3/3] feat(example): minimal loop benchmark --- .../bench_01_minimal_loop/bdsim_case.py | 66 ++++++++ .../bench_01_minimal_loop/compare.py | 118 ++++++++++++++ .../bench_01_minimal_loop/noise_seq.npy | Bin 0 -> 944 bytes .../bench_01_minimal_loop/params.py | 14 ++ .../bench_01_minimal_loop/pathsim_case.py | 151 ++++++++++++++++++ .../bench_01_minimal_loop/project.yaml | 105 ++++++++++++ .../bench_01_minimal_loop/pysimblocks_case.py | 44 +++++ 7 files changed, 498 insertions(+) create mode 100644 examples/benchmarks/bench_01_minimal_loop/bdsim_case.py create mode 100644 examples/benchmarks/bench_01_minimal_loop/compare.py create mode 100644 examples/benchmarks/bench_01_minimal_loop/noise_seq.npy create mode 100644 examples/benchmarks/bench_01_minimal_loop/params.py create mode 100644 examples/benchmarks/bench_01_minimal_loop/pathsim_case.py create mode 100644 examples/benchmarks/bench_01_minimal_loop/project.yaml create mode 100644 examples/benchmarks/bench_01_minimal_loop/pysimblocks_case.py diff --git a/examples/benchmarks/bench_01_minimal_loop/bdsim_case.py b/examples/benchmarks/bench_01_minimal_loop/bdsim_case.py new file mode 100644 index 0000000..b98ad3e --- /dev/null +++ b/examples/benchmarks/bench_01_minimal_loop/bdsim_case.py @@ -0,0 +1,66 @@ +import os +import time +from contextlib import redirect_stdout + +import bdsim +import numpy as np +import params as prm + +# params + +def test_bdsim_case(): + sim = bdsim.BDSim(animation=False, progress=False, verbose=False, toolboxes=False) + bd = sim.blockdiagram() # create an empty block diagram + + tuples = [(k * prm.dt, float(prm.noise_sequence[k])) for k in range(prm.N + 2)] + clock = bd.clock(prm.dt) + +# define the blocks + src = bd.PIECEWISE(seq=tuples, name='noise') + gain_alpha = bd.GAIN(prm.alpha) + gain_1malpha = bd.GAIN(1 - prm.alpha) + sum_block = bd.SUM('++') + zoh = bd.ZOH(clock, x0=prm.x0) + +# connect the blocks + bd.connect(src, gain_alpha) + bd.connect(gain_alpha, sum_block[0]) + bd.connect(gain_1malpha, sum_block[1]) + bd.connect(sum_block, zoh) + bd.connect(zoh, gain_1malpha) + + bd.compile(report=False, verbose=False) + + + t0 = time.perf_counter() + with redirect_stdout(open(os.devnull, 'w')): + out = sim.run(bd, T=prm.T, dt=prm.dt) + t1 = time.perf_counter() + + dt_sim = t1 - t0 + t = out.clock0.t.flatten() # flatten to 1D array + x = out.clock0.x.flatten() # flatten to 1D array + + t = np.hstack(([0], t)) + x = np.hstack(([prm.x0], x)) + + return t, prm.noise_sequence[:len(t)], x, dt_sim + +if __name__ == "__main__": + import matplotlib.pyplot as plt + + t, noise_data, output_data, dt_sim = test_bdsim_case() + + print(f"bdsim simulation completed in {dt_sim:.2f} seconds") + + # Plot results + plt.figure(figsize=(10, 6)) + plt.plot(t, noise_data, "--r", label='Noise', alpha=0.7) + plt.plot(t, output_data, "--b", label='Output', alpha=0.7) + plt.title('pySimBlocks Simulation Results') + plt.xlabel('Time (s)') + plt.ylabel('Amplitude') + plt.legend() + plt.grid() + plt.tight_layout() + plt.show() diff --git a/examples/benchmarks/bench_01_minimal_loop/compare.py b/examples/benchmarks/bench_01_minimal_loop/compare.py new file mode 100644 index 0000000..d79045a --- /dev/null +++ b/examples/benchmarks/bench_01_minimal_loop/compare.py @@ -0,0 +1,118 @@ +import logging + +import numpy as np +import matplotlib.pyplot as plt + +from bdsim_case import test_bdsim_case +from pathsim_case import test_pathsim_case +from pysimblocks_case import test_pysimblocks_case + + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + N_RUNS = 1 + + logger.info(f"Running benchmark ({N_RUNS} runs each)...") + + results_bd, results_ps, results_pb = [], [], [] + for i in range(N_RUNS): + print(f"Run {i+1}/{N_RUNS}...", end="\r") + results_bd.append(test_bdsim_case()) + results_ps.append(test_pathsim_case()) + results_pb.append(test_pysimblocks_case()) + + times_bd = np.array([r[3] for r in results_bd]) + times_ps = np.array([r[3] for r in results_ps]) + times_pb = np.array([r[3] for r in results_pb]) + + t_bd, noise_bd, output_bd, _ = results_bd[-1] + t_ps, noise_ps, output_ps, _ = results_ps[-1] + t_pb, noise_pb, output_pb, _ = results_pb[-1] + + n = min(len(t_ps), len(t_pb), len(t_bd)) + t_bd, noise_bd, output_bd = t_bd[:n], noise_bd[:n], output_bd[:n] + t_ps, noise_ps, output_ps = t_ps[:n], noise_ps[:n], output_ps[:n] + t_pb, noise_pb, output_pb = t_pb[:n], noise_pb[:n], output_pb[:n] + + t_error_bd_ps = np.linalg.norm(t_bd - t_ps) + noise_error_bd_ps = np.linalg.norm(noise_bd - noise_ps) + output_error_bd_ps = np.linalg.norm(output_bd - output_ps) + t_error_bd_pb = np.linalg.norm(t_bd - t_pb) + noise_error_bd_pb = np.linalg.norm(noise_bd - noise_pb) + output_error_bd_pb = np.linalg.norm(output_bd - output_pb) + t_error_ps_pb = np.linalg.norm(t_ps - t_pb) + noise_error_ps_pb = np.linalg.norm(noise_ps - noise_pb) + output_error_ps_pb = np.linalg.norm(output_ps - output_pb) + + logger.info("") + logger.info("=== Timing ===") + logger.info(f" bdsim: median={np.median(times_bd)*1e3:.1f} ms, std={np.std(times_bd)*1e3:.1f} ms") + logger.info(f" PathSim: median={np.median(times_ps)*1e3:.1f} ms, std={np.std(times_ps)*1e3:.1f} ms") + logger.info(f" pySimBlocks: median={np.median(times_pb)*1e3:.1f} ms, std={np.std(times_pb)*1e3:.1f} ms") + logger.info(f" Speedup pySimBlocks vs PathSim: {np.median(times_ps) / np.median(times_pb):.2f}x") + logger.info(f" Speedup pySimBlocks vs bdsim: {np.median(times_bd) / np.median(times_pb):.2f}x") + + logger.info("") + logger.info("=== Numerical errors (last run) ===") + logger.info("Librairies | Time error (a/b%) | Noise error (a/b%) | Output error (a/b%)") + logger.info(f"bdsim vs PathSim | {t_error_bd_ps:.2e} ({t_error_bd_ps / np.linalg.norm(t_ps) * 100:.2f}%) | {noise_error_bd_ps:.2e} ({noise_error_bd_ps / np.linalg.norm(noise_ps) * 100:.2f}%) | {output_error_bd_ps:.2e} ({output_error_bd_ps / np.linalg.norm(output_ps) * 100:.2f}%)") + logger.info(f"bdsim vs pySimBlocks | {t_error_bd_pb:.2e} ({t_error_bd_pb / np.linalg.norm(t_pb) * 100:.2f}%) | {noise_error_bd_pb:.2e} ({noise_error_bd_pb / np.linalg.norm(noise_pb) * 100:.2f}%) | {output_error_bd_pb:.2e} ({output_error_bd_pb / np.linalg.norm(output_pb) * 100:.2f}%)") + logger.info(f"PathSim vs pySimBlocks | {t_error_ps_pb:.2e} ({t_error_ps_pb / np.linalg.norm(t_pb) * 100:.2f}%) | {noise_error_ps_pb:.2e} ({noise_error_ps_pb / np.linalg.norm(noise_pb) * 100:.2f}%) | {output_error_ps_pb:.2e} ({output_error_ps_pb / np.linalg.norm(output_pb) * 100:.2f}%)") + + plt.figure(figsize=(10, 6)) + plt.plot(t_bd, noise_bd, "-.r", label="Noise (bdsim)", alpha=0.7) + plt.plot(t_bd, output_bd, "-.b", label="Output (bdsim)", alpha=0.7) + plt.plot(t_ps, noise_ps, "--r", label="Noise (PathSim)", alpha=0.7) + plt.plot(t_ps, output_ps, "--b", label="Output (PathSim)", alpha=0.7) + plt.plot(t_pb, noise_pb, ":r", label="Noise (pySimBlocks)", alpha=0.7) + plt.plot(t_pb, output_pb, ":b", label="Output (pySimBlocks)", alpha=0.7) + plt.title("Simulation Results Comparison") + plt.xlabel("Time (s)") + plt.ylabel("Amplitude") + plt.legend() + plt.grid() + plt.tight_layout() + + # --- Histogramme des temps --- + benchmarks = { + 'bench_01': { + 'bdsim': np.median(times_bd) * 1e3, + 'PathSim': np.median(times_ps) * 1e3, + 'pySimBlocks': np.median(times_pb) * 1e3, + }, + # 'bench_02': { 'bdsim': ..., 'PathSim': ..., 'pySimBlocks': ... }, + # 'bench_03': { 'bdsim': ..., 'PathSim': ..., 'pySimBlocks': ... }, + } + + libs = ['bdsim', 'PathSim', 'pySimBlocks'] + colors = {'bdsim': '#7F77DD', 'PathSim': '#1D9E75', 'pySimBlocks': '#D85A30'} + + n_bench = len(benchmarks) + width = 0.25 + group_gap = 1.0 # espace entre groupes + + fig, ax = plt.subplots(figsize=(4 + 2 * n_bench, 5)) + + for g, (bench_name, values) in enumerate(benchmarks.items()): + group_center = g * group_gap + offsets = [-width, 0, width] + for lib, offset in zip(libs, offsets): + val = values[lib] + bar = ax.bar(group_center + offset, val, width=width, + color=colors[lib], label=lib if g == 0 else None) + ax.bar_label(bar, fmt='%.1f', padding=3, fontsize=8) + + ax.set_xticks([g * group_gap for g in range(n_bench)]) + ax.set_xticklabels(list(benchmarks.keys())) + ax.set_ylabel('Median time (ms)') + ax.set_title('Time benchmark comparison') + ax.set_yscale('log') + ax.legend(loc='upper right') + ax.grid(axis='y', linestyle='--', alpha=0.4) + ax.spines[['top', 'right']].set_visible(False) + + plt.tight_layout() + plt.show() diff --git a/examples/benchmarks/bench_01_minimal_loop/noise_seq.npy b/examples/benchmarks/bench_01_minimal_loop/noise_seq.npy new file mode 100644 index 0000000000000000000000000000000000000000..53a3d3cc4df4996de77fc12e591b3ac76297c1f1 GIT binary patch literal 944 zcmbWr{ZrBh9KdnV**S}?H6PMz3rj(bE_K~CAI=ZR?6Q?MjjQQ-jTBQ)tHEsZQt{Ng zQmfTDTV`&$)TWtkU6&T<2htc3DpS-=^Y8(_!U>8zYP4GW7xw<`{i;pk96s_TnRJeH zo}R@MWb*0JRC;Xor*sCDo_&V@4WE1Z#2J1T??2zq%@^?Ax*&&pmiP8UBElmXp$uvX z^?&EZs+_Je`AI;T&}SIYOR(U#oR(vyS}?0CjiRZ?U}6(n_Xf=&@#k>eiJ(b{-P}jp zGh>6>;#_fY(lUf}Pf6cY5-5`2stW@fx@^?Mnbiec*i)j-!!Hs1Z zWF?l)$A7_Gv7(c9$s#(vi0YOS)wR4i3{k1w*l}uHlbUbRk557E$%n!nU1sq7NkfbZ zBzV?yOzAmehwU`?WBU_z$k(gzHh zOun+X4lg_W&`a`7(M$j+HZ3J^teEGY$`Z!$vAM+m(Ii<12kyHYsEsx}6m#?4uEf7# z#e(h&<EKlQWWA)zh9t%b*AXlJvfr9Cu zM(WexTC+a@Eab|@K&>COL43z;dy&z?1rB@(jy zUiIw`cv?5`LOQF13S*vo^ky^sasB7l+NTRhIBA)7+CNZ}ayhD)YR8uDld9NVI{ckC zZ#6BMpw$)ep!=hC@cuC96!8v;>4;=C@B-x^?d)Vy*;>3lF8`Pm_7`qTZf)iiu3|mK z_sLgZ+fbr+^&DkrLD(zWXt*&Colf`UiibAPccyI=1?#bGIJ&BCg9{GM)#iSmHHy*= zY_TkS9r~(lIRp2naio2TEE;GY6eOQ#EVG!_>k<~WwJVvJ1 h4E<7Q$Agqtg-!Vmlx%