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 0000000..53a3d3c
Binary files /dev/null and b/examples/benchmarks/bench_01_minimal_loop/noise_seq.npy differ
diff --git a/examples/benchmarks/bench_01_minimal_loop/params.py b/examples/benchmarks/bench_01_minimal_loop/params.py
new file mode 100644
index 0000000..dd1a9e4
--- /dev/null
+++ b/examples/benchmarks/bench_01_minimal_loop/params.py
@@ -0,0 +1,14 @@
+import numpy as np
+
+dt = 0.1
+N = 100
+T = N * dt
+alpha = 0.1
+
+# noise
+std = 1.
+seed = 0
+rng = np.random.default_rng(seed)
+noise_sequence = rng.standard_normal(N + 2)
+np.save("noise_seq.npy", noise_sequence)
+x0 = 0.
diff --git a/examples/benchmarks/bench_01_minimal_loop/pathsim_case.py b/examples/benchmarks/bench_01_minimal_loop/pathsim_case.py
new file mode 100644
index 0000000..7167988
--- /dev/null
+++ b/examples/benchmarks/bench_01_minimal_loop/pathsim_case.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+PathSim Simulation
+==================
+
+Generated by PathView on 2026-04-18 09:41:48
+https://view.pathsim.org
+
+PathSim documentation: https://docs.pathsim.org
+"""
+
+# ────────────────────────────────────────────────────────────────────────────
+# IMPORTS
+# ────────────────────────────────────────────────────────────────────────────
+
+import time
+import numpy as np
+
+from pathsim import Simulation, Connection
+from pathsim.blocks import (
+ Adder,
+ Amplifier,
+ Delay,
+ Scope,
+ Source,
+)
+from pathsim.solvers import RK4
+
+import params as prm
+
+def make_noise_source(seq, dt):
+ def f(t):
+ k = int(round(t / dt))
+ return float(seq[min(k, len(seq) - 1)])
+ return Source(f)
+
+
+def test_pathsim_case():
+
+ # ────────────────────────────────────────────────────────────────────────────
+ # BLOCKS
+ # ────────────────────────────────────────────────────────────────────────────
+
+ # Sources
+ whitenoise = make_noise_source(prm.noise_sequence, prm.dt)
+
+ # Dynamic
+ delay = Delay(
+ tau=prm.dt
+ )
+
+ # Algebraic
+ adder = Adder(
+ operations="++"
+ )
+ amplifier = Amplifier(
+ gain=prm.alpha
+ )
+ block_4 = Amplifier(
+ gain=1 - prm.alpha
+ )
+
+ # Recording
+ result = Scope()
+
+ blocks = [
+ whitenoise,
+ delay,
+ adder,
+ amplifier,
+ block_4,
+ result,
+ ]
+
+ # ────────────────────────────────────────────────────────────────────────────
+ # CONNECTIONS
+ # ────────────────────────────────────────────────────────────────────────────
+
+ conn_0 = Connection(adder[0], delay[0])
+ conn_1 = Connection(amplifier[0], adder[0])
+ conn_2 = Connection(block_4[0], adder[1])
+ conn_3 = Connection(whitenoise[0], amplifier[0])
+ conn_4 = Connection(delay[0], block_4[0])
+ conn_5 = Connection(whitenoise[0], result[0])
+ conn_6 = Connection(delay[0], result[1])
+
+ connections = [
+ conn_0,
+ conn_1,
+ conn_2,
+ conn_3,
+ conn_4,
+ conn_5,
+ conn_6,
+ ]
+
+ # ────────────────────────────────────────────────────────────────────────────
+ # SIMULATION
+ # ────────────────────────────────────────────────────────────────────────────
+
+ sim = Simulation(
+ blocks,
+ connections,
+ Solver=RK4,
+ dt=prm.dt,
+ dt_min=prm.dt,
+ dt_max=prm.dt,
+ tolerance_lte_rel=0.0001,
+ tolerance_lte_abs=1e-08,
+ tolerance_fpi=1e-10,
+ log=False,
+ )
+
+ # ────────────────────────────────────────────────────────────────────────────
+ # MAIN
+ # ────────────────────────────────────────────────────────────────────────────
+
+ # Run simulation
+ t0 = time.perf_counter()
+ sim.run(duration=prm.T, reset=True, adaptive=False)
+ t1 = time.perf_counter()
+ dt_sim = t1 - t0
+
+ t = np.array(result.recording_time)
+ data = np.array(result.recording_data)
+ noise_data = data[:, 0]
+ output_data = data[:, 1]
+
+ return t, noise_data, output_data, dt_sim
+
+if __name__ == "__main__":
+
+ import matplotlib.pyplot as plt
+
+ t, noise_data, output_data, dt_sim = test_pathsim_case()
+
+ print(f"PathSim 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('PathSim 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/project.yaml b/examples/benchmarks/bench_01_minimal_loop/project.yaml
new file mode 100644
index 0000000..ab5ba26
--- /dev/null
+++ b/examples/benchmarks/bench_01_minimal_loop/project.yaml
@@ -0,0 +1,105 @@
+schema_version: 1
+project:
+ name: bench_01_minimal_loop
+simulation:
+ dt: '#dt'
+ T: '#T'
+ solver: fixed
+ external_module: params.py
+ logging:
+ - Delay.outputs.out
+ - WhiteNoise.outputs.out
+ plots:
+ - title: Results
+ signals:
+ - Delay.outputs.out
+ - WhiteNoise.outputs.out
+diagram:
+ blocks:
+ - name: Sum
+ category: operators
+ type: sum
+ parameters:
+ signs: ++
+ - name: Delay
+ category: operators
+ type: delay
+ parameters:
+ num_delays: 1
+ initial_output: '#x0'
+ - name: alpha
+ category: operators
+ type: gain
+ parameters:
+ gain: '#alpha'
+ multiplication: Element wise (K * u)
+ - name: 1-alpha
+ category: operators
+ type: gain
+ parameters:
+ gain: 1-#alpha
+ multiplication: Element wise (K * u)
+ - name: WhiteNoise
+ category: sources
+ type: file_source
+ parameters:
+ file_path: noise_seq.npy
+ repeat: false
+ connections:
+ - name: c1
+ ports:
+ - alpha.out
+ - Sum.in1
+ - name: c2
+ ports:
+ - 1-alpha.out
+ - Sum.in2
+ - name: c3
+ ports:
+ - Delay.out
+ - 1-alpha.in
+ - name: c4
+ ports:
+ - Sum.out
+ - Delay.in
+ - name: c5
+ ports:
+ - WhiteNoise.out
+ - alpha.in
+gui:
+ layout:
+ blocks:
+ Sum:
+ x: -555.0
+ y: -275.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ Delay:
+ x: -380.0
+ y: -265.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ alpha:
+ x: -720.0
+ y: -285.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ 1-alpha:
+ x: -715.0
+ y: -160.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ WhiteNoise:
+ x: -900.0
+ y: -285.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ connections:
+ c3:
+ route: [[-245.0, -235.0], [-237.0, -235.0], [-237.0, -70.0], [-729.0, -70.0],
+ [-729.0, -130.0], [-721.0, -130.0]]
diff --git a/examples/benchmarks/bench_01_minimal_loop/pysimblocks_case.py b/examples/benchmarks/bench_01_minimal_loop/pysimblocks_case.py
new file mode 100644
index 0000000..8ef3285
--- /dev/null
+++ b/examples/benchmarks/bench_01_minimal_loop/pysimblocks_case.py
@@ -0,0 +1,44 @@
+import time
+from pathlib import Path
+from pySimBlocks.project import load_simulator_from_project
+
+
+try:
+ BASE_DIR = Path(__file__).parent.resolve()
+except Exception:
+ BASE_DIR = Path("")
+
+def test_pysimblocks_case():
+ sim, plot_cfg = load_simulator_from_project(BASE_DIR / 'project.yaml')
+
+ t0 = time.perf_counter()
+ _ = sim.run()
+ t1 = time.perf_counter()
+ dt_sim = t1 - t0
+
+ t = sim.get_data("time").reshape(-1)
+ noise_data = sim.get_data(block="WhiteNoise", port="out").reshape(-1)
+ output_data = sim.get_data(block="Delay", port="out").reshape(-1)
+
+ return t, noise_data, output_data, dt_sim
+
+# Plot results
+if __name__ == "__main__":
+ import matplotlib.pyplot as plt
+
+ t, noise_data, output_data, dt_sim = test_pysimblocks_case()
+
+ print(f"pySimBlocks 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/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/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)
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)