diff --git a/README.md b/README.md
index c0faa92..0b8498d 100644
--- a/README.md
+++ b/README.md
@@ -42,11 +42,12 @@ pip install .
The following example models a damped harmonic oscillator:
-$$ \ddot{x} +0.5 \dot{x} +2 x = 0 $$
+$$ \ddot{x} +0.5\dot{x} +2x = 0 $$
-The continuous-time equation is implemented using explicit forward Euler discretization through discrete integrator blocks with a fixed time step.
+The continuous-time equation is implemented using explicit forward Euler
+discretization through discrete integrator blocks with a fixed time step.
-The system is assembled explicitly from discrete operators.
+The system is assembled explicitly from discrete-time operators.
```python
from pySimBlocks import Model, Simulator, SimulationConfig, PlotConfig
@@ -54,23 +55,23 @@ from pySimBlocks.blocks.operators import Gain, Sum, DiscreteIntegrator
from pySimBlocks.project.plot_from_config import plot_from_config
# 1. Create the blocks
-I1 = DiscreteIntegrator("x", initial_state=5)
-I2 = DiscreteIntegrator("v", initial_state=2.)
-A1 = Gain(name="damping", gain=0.5)
-A2 = Gain(name="stiffness", gain=2)
-S = Sum(name="sum", signs="--")
+v = DiscreteIntegrator("v", initial_state=5)
+x = DiscreteIntegrator("x", initial_state=2.)
+damping = Gain(name="damping", gain=0.5)
+stiffness = Gain(name="stiffness", gain=2)
+sum = Sum(name="sum", signs="--")
# 2. Build the model
model = Model("Example")
-for block in [I1, I2, A1, A2, S]:
+for block in [v, x, damping, stiffness, sum]:
model.add_block(block)
-model.connect("x", "out", "v", "in")
-model.connect("x", "out", "damping", "in")
-model.connect("v", "out", "stiffness", "in")
+model.connect("v", "out", "x", "in")
+model.connect("v", "out", "damping", "in")
+model.connect("x", "out", "stiffness", "in")
model.connect("damping", "out", "sum", "in1")
model.connect("stiffness", "out", "sum", "in2")
-model.connect("sum", "out", "x", "in")
+model.connect("sum", "out", "v", "in")
# 3. Create the simulator
sim_cfg = SimulationConfig(dt=0.05, T=30.)
@@ -93,13 +94,20 @@ plot_from_config(logs, plot_cfg)
The simulated position and velocity exhibit the expected damped oscillatory behavior.
-
+
+
+See [examples/quick_start/oscillator.py](./examples/quick_start/oscillator.py)
+to run the example yourself.
### Graphical Editor
+The same model can be constructed visually using the graphical editor:
+
+
+
To open the graphical editor, run:
```bash
-pysimblocks
+pysimblocks examples/quick_start/gui
```
### Tutorials
diff --git a/docs/User_Guide/images/gui_example.png b/docs/User_Guide/images/gui_example.png
new file mode 100644
index 0000000..5bf21ea
Binary files /dev/null and b/docs/User_Guide/images/gui_example.png differ
diff --git a/examples/quick_start/gui/layout.yaml b/examples/quick_start/gui/layout.yaml
new file mode 100644
index 0000000..71fb349
--- /dev/null
+++ b/examples/quick_start/gui/layout.yaml
@@ -0,0 +1,41 @@
+version: 1
+blocks:
+ v:
+ x: -570.0
+ y: -300.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ x:
+ x: -370.0
+ y: -300.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ Sum:
+ x: -740.0
+ y: -300.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ damping:
+ x: -570.0
+ y: -210.0
+ orientation: flipped
+ width: 130.0
+ height: 65.0
+ stiffness:
+ x: -555.0
+ y: -410.0
+ orientation: flipped
+ width: 135.0
+ height: 65.0
+connections:
+ damping.out -> Sum.in2:
+ ports: [damping.out, Sum.in2]
+ route: [[-585.0, -177.5], [-755.0, -177.5], [-755.0, -225.0], [-755.0, -225.0],
+ [-755.0, -260.0], [-746.0, -260.0]]
+ stiffness.out -> Sum.in1:
+ ports: [stiffness.out, Sum.in1]
+ route: [[-570.0, -377.5], [-755.0, -377.5], [-755.0, -322.5], [-755.0, -322.5],
+ [-755.0, -280.0], [-746.0, -280.0]]
diff --git a/examples/quick_start/gui/model.yaml b/examples/quick_start/gui/model.yaml
new file mode 100644
index 0000000..4675807
--- /dev/null
+++ b/examples/quick_start/gui/model.yaml
@@ -0,0 +1,23 @@
+blocks:
+- name: v
+ category: operators
+ type: discrete_integrator
+- name: x
+ category: operators
+ type: discrete_integrator
+- name: Sum
+ category: operators
+ type: sum
+- name: damping
+ category: operators
+ type: gain
+- name: stiffness
+ category: operators
+ type: gain
+connections:
+- [Sum.out, v.in]
+- [v.out, x.in]
+- [v.out, damping.in]
+- [x.out, stiffness.in]
+- [damping.out, Sum.in2]
+- [stiffness.out, Sum.in1]
diff --git a/examples/quick_start/gui/parameters.yaml b/examples/quick_start/gui/parameters.yaml
new file mode 100644
index 0000000..63b88aa
--- /dev/null
+++ b/examples/quick_start/gui/parameters.yaml
@@ -0,0 +1,27 @@
+simulation:
+ dt: 0.05
+ T: 30.0
+ solver: fixed
+blocks:
+ v:
+ initial_state: 5.0
+ method: euler forward
+ x:
+ initial_state: 2.0
+ method: euler forward
+ Sum:
+ signs: --
+ damping:
+ gain: 0.5
+ multiplication: Element wise (K * u)
+ stiffness:
+ gain: 2.0
+ multiplication: Element wise (K * u)
+logging:
+- v.outputs.out
+- x.outputs.out
+plots:
+- title: Position and Velocity
+ signals:
+ - v.outputs.out
+ - x.outputs.out
diff --git a/examples/quick_start/gui/run.py b/examples/quick_start/gui/run.py
new file mode 100644
index 0000000..a19b1f6
--- /dev/null
+++ b/examples/quick_start/gui/run.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+from pySimBlocks.core import Model, Simulator
+from pySimBlocks.project.load_project_config import load_project_config
+from pySimBlocks.project.plot_from_config import plot_from_config
+
+try:
+ BASE_DIR = Path(__file__).parent.resolve()
+except Exception:
+ BASE_DIR = Path("")
+
+sim_cfg, model_cfg, plot_cfg = load_project_config(BASE_DIR / 'parameters.yaml')
+
+model = Model(
+ name="model",
+ model_yaml=BASE_DIR / 'model.yaml',
+ model_cfg=model_cfg
+)
+
+sim = Simulator(model, sim_cfg)
+
+logs = sim.run()
+if True:
+ plot_from_config(logs, plot_cfg)
diff --git a/examples/quick_start/oscillator.py b/examples/quick_start/oscillator.py
new file mode 100644
index 0000000..bef36ea
--- /dev/null
+++ b/examples/quick_start/oscillator.py
@@ -0,0 +1,41 @@
+from pySimBlocks import Model, Simulator, SimulationConfig, PlotConfig
+from pySimBlocks.blocks.operators import Gain, Sum, DiscreteIntegrator
+from pySimBlocks.project.plot_from_config import plot_from_config
+
+# 1. Create the blocks
+v = DiscreteIntegrator("v", initial_state=5)
+x = DiscreteIntegrator("x", initial_state=2.)
+damping = Gain(name="damping", gain=0.5)
+stiffness = Gain(name="stiffness", gain=2)
+sum = Sum(name="sum", signs="--")
+
+# 2. Build the model
+model = Model("Example")
+for block in [v, x, damping, stiffness, sum]:
+ model.add_block(block)
+
+model.connect("v", "out", "x", "in")
+model.connect("v", "out", "damping", "in")
+model.connect("x", "out", "stiffness", "in")
+model.connect("damping", "out", "sum", "in1")
+model.connect("stiffness", "out", "sum", "in2")
+model.connect("sum", "out", "v", "in")
+
+# 3. Create the simulator
+sim_cfg = SimulationConfig(dt=0.05, T=30.)
+sim = Simulator(model, sim_cfg)
+
+# 4. Run the simulation
+logs = sim.run(logging=[
+ "x.outputs.out",
+ "v.outputs.out",
+ ]
+)
+
+# 5. Plot the results
+plot_cfg = PlotConfig([
+ {"title": "Position and Velocity",
+ "signals": ["x.outputs.out", "v.outputs.out"],},
+ ])
+plot_from_config(logs, plot_cfg)
+
diff --git a/pySimBlocks/blocks/__init__.py b/pySimBlocks/blocks/__init__.py
index 997e188..fa5a8ea 100644
--- a/pySimBlocks/blocks/__init__.py
+++ b/pySimBlocks/blocks/__init__.py
@@ -25,7 +25,14 @@
DeadZone, Delay, DiscreteDerivator, DiscreteIntegrator,
Gain, Mux, Product, RateLimiter, Saturation, Sum, ZeroOrderHold
)
-from pySimBlocks.blocks.sources import Constant, Ramp, Step, Sinusoidal, WhiteNoise
+from pySimBlocks.blocks.sources import (
+ Constant,
+ FunctionSource,
+ Ramp,
+ Sinusoidal,
+ Step,
+ WhiteNoise,
+)
from pySimBlocks.blocks.systems import LinearStateSpace, PolytopicStateSpace
__all__ = [
@@ -50,6 +57,7 @@
"ZeroOrderHold",
"Constant",
+ "FunctionSource",
"Ramp",
"Step",
"Sinusoidal",
diff --git a/pySimBlocks/blocks/sources/__init__.py b/pySimBlocks/blocks/sources/__init__.py
index 01d57b5..d627485 100644
--- a/pySimBlocks/blocks/sources/__init__.py
+++ b/pySimBlocks/blocks/sources/__init__.py
@@ -20,6 +20,7 @@
from pySimBlocks.blocks.sources.constant import Constant
from pySimBlocks.blocks.sources.file_source import FileSource
+from pySimBlocks.blocks.sources.function_source import FunctionSource
from pySimBlocks.blocks.sources.ramp import Ramp
from pySimBlocks.blocks.sources.step import Step
from pySimBlocks.blocks.sources.sinusoidal import Sinusoidal
@@ -28,6 +29,7 @@
__all__ = [
"Constant",
"FileSource",
+ "FunctionSource",
"Ramp",
"Step",
"Sinusoidal",
diff --git a/pySimBlocks/blocks/sources/function_source.py b/pySimBlocks/blocks/sources/function_source.py
new file mode 100644
index 0000000..147732b
--- /dev/null
+++ b/pySimBlocks/blocks/sources/function_source.py
@@ -0,0 +1,170 @@
+# ******************************************************************************
+# 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
+# ******************************************************************************
+
+import importlib.util
+import inspect
+from pathlib import Path
+from typing import Any, Callable, Dict
+
+import numpy as np
+
+from pySimBlocks.core.block_source import BlockSource
+
+
+class FunctionSource(BlockSource):
+ """
+ User-defined source block with no inputs.
+
+ Summary:
+ Computes:
+ y = f(t, dt)
+
+ Notes:
+ - The function must accept exactly (t, dt).
+ - Returned value can be scalar, 1D, or 2D (internally normalized to 2D).
+ - Output shape is frozen after first successful evaluation.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ function: Callable,
+ sample_time: float | None = None,
+ ):
+ super().__init__(name, sample_time)
+
+ if function is None or not callable(function):
+ raise TypeError(f"[{self.name}] 'function' must be callable.")
+
+ self._func = function
+ self._out_shape: tuple[int, int] | None = None
+ self.outputs["out"] = np.zeros((1, 1), dtype=float)
+
+ # --------------------------------------------------------------------------
+ # Class Methods
+ # --------------------------------------------------------------------------
+ @classmethod
+ def adapt_params(
+ cls,
+ params: Dict[str, Any],
+ params_dir: Path | None = None,
+ ) -> Dict[str, Any]:
+ """
+ Adapt YAML parameters by loading a callable from (file_path, function_name).
+ """
+ adapted = dict(params)
+
+ if "function" in adapted:
+ return adapted
+
+ has_file = "file_path" in adapted
+ has_name = "function_name" in adapted
+ if not has_file and not has_name:
+ return adapted
+ if not has_file or not has_name:
+ raise ValueError(
+ "FunctionSource adapter requires both 'file_path' and 'function_name'."
+ )
+
+ path = Path(adapted["file_path"])
+ if not path.is_absolute() and params_dir is not None:
+ path = (params_dir / path).resolve()
+
+ if not path.exists():
+ raise FileNotFoundError(f"Function file not found: {path}")
+
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+
+ func_name = adapted["function_name"]
+ try:
+ func = getattr(module, func_name)
+ except AttributeError:
+ raise AttributeError(f"Function '{func_name}' not found in {path}")
+
+ if not callable(func):
+ raise TypeError(f"'{func_name}' in {path} is not callable")
+
+ adapted.pop("file_path", None)
+ adapted.pop("function_name", None)
+ adapted["function"] = func
+ return adapted
+
+ # --------------------------------------------------------------------------
+ # Public Methods
+ # --------------------------------------------------------------------------
+ def initialize(self, t0: float) -> None:
+ self._validate_signature()
+ self.outputs["out"] = self._call_func(t0, 0.0)
+
+ # ------------------------------------------------------------------
+ def output_update(self, t: float, dt: float) -> None:
+ self.outputs["out"] = self._call_func(t, dt)
+
+ # --------------------------------------------------------------------------
+ # Private Methods
+ # --------------------------------------------------------------------------
+ def _call_func(self, t: float, dt: float) -> np.ndarray:
+ try:
+ y = self._func(t, dt)
+ except Exception as e:
+ raise RuntimeError(f"[{self.name}] function call error: {e}")
+
+ y = self._to_2d_array("out", y, dtype=float)
+ if y.ndim != 2:
+ raise ValueError(
+ f"[{self.name}] function output must be scalar, 1D, or 2D."
+ )
+
+ if self._out_shape is None:
+ self._out_shape = y.shape
+ return y
+
+ if y.shape != self._out_shape:
+ raise ValueError(
+ f"[{self.name}] output 'out' shape changed: expected "
+ f"{self._out_shape}, got {y.shape}."
+ )
+
+ return y
+
+ # ------------------------------------------------------------------
+ def _validate_signature(self) -> None:
+ sig = inspect.signature(self._func)
+ params = list(sig.parameters.values())
+
+ if len(params) != 2:
+ raise ValueError(
+ f"[{self.name}] function must have exactly arguments (t, dt)."
+ )
+ if params[0].name != "t" or params[1].name != "dt":
+ raise ValueError(
+ f"[{self.name}] function arguments must be exactly (t, dt)."
+ )
+
+ for p in params:
+ if p.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD,):
+ raise ValueError(f"[{self.name}] *args and **kwargs are not allowed.")
+ if p.default is not inspect.Parameter.empty:
+ raise ValueError(
+ f"[{self.name}] default values are not allowed in function signature."
+ )
diff --git a/pySimBlocks/docs/blocks/sources/function_source.md b/pySimBlocks/docs/blocks/sources/function_source.md
new file mode 100644
index 0000000..1982473
--- /dev/null
+++ b/pySimBlocks/docs/blocks/sources/function_source.md
@@ -0,0 +1,52 @@
+# FunctionSource
+
+## Summary
+
+The **FunctionSource** block generates a signal from a user-defined Python function
+without any input ports.
+
+At each activation, it evaluates:
+
+$$
+y[k] = f(t_k, \Delta t_k)
+$$
+
+where $t_k$ is the current simulation time and $\Delta t_k$ is the elapsed time
+since the previous activation.
+
+---
+
+## Parameters
+
+| Name | Type | Description | Required |
+|------|------|-------------|----------|
+| `file_path` | string | Path to the Python file containing `f`. | Yes |
+| `function_name` | string | Name of the function to call inside the file. | Yes |
+| `sample_time` | float | Execution period of the block. If omitted, the global simulation time step is used. | No |
+
+---
+
+## Inputs
+
+This block has **no inputs**.
+
+---
+
+## Outputs
+
+| Port | Description |
+|------|-------------|
+| `out` | Function output signal. |
+
+---
+
+## Execution semantics
+
+- The function signature must be exactly: `f(t, dt)`.
+- The returned value may be scalar, 1D, or 2D and is normalized to a 2D array.
+- The output shape is frozen after first evaluation and must stay constant.
+- The block is stateless.
+
+
+---
+© 2026 Université de Lille & INRIA – Licensed under LGPL-3.0-or-later
diff --git a/pySimBlocks/gui/blocks/sources/function_source.py b/pySimBlocks/gui/blocks/sources/function_source.py
new file mode 100644
index 0000000..80aed1d
--- /dev/null
+++ b/pySimBlocks/gui/blocks/sources/function_source.py
@@ -0,0 +1,163 @@
+# ******************************************************************************
+# 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
+# ******************************************************************************
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton
+
+from pySimBlocks.gui.blocks.block_meta import BlockMeta, ParameterMeta
+from pySimBlocks.gui.blocks.port_meta import PortMeta
+
+
+class FunctionSourceMeta(BlockMeta):
+
+ def __init__(self):
+ self.name = "FunctionSource"
+ self.category = "sources"
+ self.type = "function_source"
+ self.summary = "User-defined source block y = f(t, dt)."
+ self.description = (
+ "This block evaluates a user-provided Python function with no inputs:\n\n"
+ " y = f(t, dt)\n\n"
+ "The function is loaded from an external Python file and executed at each\n"
+ "activation. The output is exposed on the `out` port."
+ )
+
+ self.parameters = [
+ ParameterMeta(
+ name="file_path",
+ type="string",
+ required=True,
+ description=(
+ "Path to the Python file containing the function, relative to "
+ "the parameters.yaml file."
+ ),
+ ),
+ ParameterMeta(
+ name="function_name",
+ type="string",
+ required=True,
+ description="Name of the function to call inside the Python file.",
+ ),
+ ParameterMeta(
+ name="sample_time",
+ type="float",
+ description="Optional execution period of the block.",
+ ),
+ ]
+
+ self.outputs = [
+ PortMeta(
+ name="out",
+ display_as="out",
+ shape=["n", "m"],
+ description="Function output signal.",
+ )
+ ]
+
+ # --------------------------------------------------------------------------
+ # Dialog methods
+ # --------------------------------------------------------------------------
+ def build_param(
+ self,
+ session,
+ form: QFormLayout,
+ readonly: bool = False,
+ ):
+ name_edit = QLineEdit(session.instance.name)
+ name_edit.textChanged.connect(
+ lambda val: self._on_param_changed(val, "name", session, readonly)
+ )
+ form.addRow(QLabel("Block name:"), name_edit)
+ if readonly:
+ name_edit.setReadOnly(True)
+ session.name_edit = name_edit
+
+ for pmeta in self.parameters:
+ if pmeta.name == "file_path":
+ self.build_file_param_row(
+ session,
+ form,
+ pmeta,
+ readonly=readonly,
+ file_filter="Python files (*.py);;All files (*)",
+ )
+ continue
+
+ label, widget = self._create_param_row(session, pmeta, readonly)
+ if widget is None:
+ continue
+ if readonly:
+ self._set_readonly_style(widget)
+
+ form.addRow(label, widget)
+ session.param_widgets[pmeta.name] = widget
+ session.param_labels[pmeta.name] = label
+
+ # ------------------------------------------------------
+ def build_post_param(self, session, form: QFormLayout, readonly: bool = False):
+ open_btn = QPushButton("Open file")
+ open_btn.clicked.connect(lambda: self._open_file_from_session(session))
+ form.addRow(QLabel(""), open_btn)
+ session.open_file_btn = open_btn
+ self._refresh_open_button_state(session)
+
+ # ------------------------------------------------------
+ def refresh_form(self, session):
+ super().refresh_form(session)
+ self._refresh_open_button_state(session)
+
+ # ------------------------------------------------------
+ def _resolve_file_path(self, session) -> Path | None:
+ raw = session.local_params.get("file_path")
+ if not raw:
+ return None
+
+ path = Path(str(raw)).expanduser()
+ if not path.is_absolute() and session.project_dir is not None:
+ path = (session.project_dir / path).resolve()
+ return path
+
+ # ------------------------------------------------------
+ def _refresh_open_button_state(self, session) -> None:
+ btn = getattr(session, "open_file_btn", None)
+ if btn is None:
+ return
+
+ target = self._resolve_file_path(session)
+ exists = target is not None and target.is_file()
+ btn.setEnabled(exists)
+ if exists:
+ btn.setToolTip(str(target))
+ else:
+ btn.setToolTip("Set a valid existing file_path to open the file.")
+
+ # ------------------------------------------------------
+ def _open_file_from_session(self, session) -> None:
+ target = self._resolve_file_path(session)
+ if target is None or not target.is_file():
+ return
+
+ if sys.platform.startswith("darwin"):
+ subprocess.Popen(["open", str(target)])
+ elif os.name == "nt":
+ os.startfile(str(target))
+ else:
+ subprocess.Popen(["xdg-open", str(target)])
diff --git a/pySimBlocks/project/pySimBlocks_blocks_index.yaml b/pySimBlocks/project/pySimBlocks_blocks_index.yaml
index 6c6e166..8fb196f 100644
--- a/pySimBlocks/project/pySimBlocks_blocks_index.yaml
+++ b/pySimBlocks/project/pySimBlocks_blocks_index.yaml
@@ -70,6 +70,9 @@ sources:
file_source:
class: FileSource
module: pySimBlocks.blocks.sources.file_source
+ function_source:
+ class: FunctionSource
+ module: pySimBlocks.blocks.sources.function_source
ramp:
class: Ramp
module: pySimBlocks.blocks.sources.ramp
diff --git a/test/blocks/controllers/test_pid.py b/tests/blocks/controllers/test_pid.py
similarity index 99%
rename from test/blocks/controllers/test_pid.py
rename to tests/blocks/controllers/test_pid.py
index 222c9a2..3c2acdb 100644
--- a/test/blocks/controllers/test_pid.py
+++ b/tests/blocks/controllers/test_pid.py
@@ -1,4 +1,4 @@
-# test/blocks/controllers/test_pid.py
+# tests/blocks/controllers/test_pid.py
import numpy as np
import pytest
diff --git a/test/blocks/controllers/test_state_feedback.py b/tests/blocks/controllers/test_state_feedback.py
similarity index 100%
rename from test/blocks/controllers/test_state_feedback.py
rename to tests/blocks/controllers/test_state_feedback.py
diff --git a/test/blocks/observers/test_luenberger.py b/tests/blocks/observers/test_luenberger.py
similarity index 99%
rename from test/blocks/observers/test_luenberger.py
rename to tests/blocks/observers/test_luenberger.py
index 0d0a8b1..83b66f2 100644
--- a/test/blocks/observers/test_luenberger.py
+++ b/tests/blocks/observers/test_luenberger.py
@@ -1,4 +1,4 @@
-# test/blocks/observers/test_luenberger.py
+# tests/blocks/observers/test_luenberger.py
import numpy as np
import pytest
diff --git a/test/blocks/operators/test_algebraic_function.py b/tests/blocks/operators/test_algebraic_function.py
similarity index 100%
rename from test/blocks/operators/test_algebraic_function.py
rename to tests/blocks/operators/test_algebraic_function.py
diff --git a/test/blocks/operators/test_dead_zone.py b/tests/blocks/operators/test_dead_zone.py
similarity index 100%
rename from test/blocks/operators/test_dead_zone.py
rename to tests/blocks/operators/test_dead_zone.py
diff --git a/test/blocks/operators/test_delay.py b/tests/blocks/operators/test_delay.py
similarity index 100%
rename from test/blocks/operators/test_delay.py
rename to tests/blocks/operators/test_delay.py
diff --git a/test/blocks/operators/test_demux.py b/tests/blocks/operators/test_demux.py
similarity index 100%
rename from test/blocks/operators/test_demux.py
rename to tests/blocks/operators/test_demux.py
diff --git a/test/blocks/operators/test_discrete_derivator.py b/tests/blocks/operators/test_discrete_derivator.py
similarity index 100%
rename from test/blocks/operators/test_discrete_derivator.py
rename to tests/blocks/operators/test_discrete_derivator.py
diff --git a/test/blocks/operators/test_discrete_integrator.py b/tests/blocks/operators/test_discrete_integrator.py
similarity index 100%
rename from test/blocks/operators/test_discrete_integrator.py
rename to tests/blocks/operators/test_discrete_integrator.py
diff --git a/test/blocks/operators/test_gain.py b/tests/blocks/operators/test_gain.py
similarity index 100%
rename from test/blocks/operators/test_gain.py
rename to tests/blocks/operators/test_gain.py
diff --git a/test/blocks/operators/test_mux.py b/tests/blocks/operators/test_mux.py
similarity index 100%
rename from test/blocks/operators/test_mux.py
rename to tests/blocks/operators/test_mux.py
diff --git a/test/blocks/operators/test_product.py b/tests/blocks/operators/test_product.py
similarity index 100%
rename from test/blocks/operators/test_product.py
rename to tests/blocks/operators/test_product.py
diff --git a/test/blocks/operators/test_rate_limiter.py b/tests/blocks/operators/test_rate_limiter.py
similarity index 100%
rename from test/blocks/operators/test_rate_limiter.py
rename to tests/blocks/operators/test_rate_limiter.py
diff --git a/test/blocks/operators/test_saturation.py b/tests/blocks/operators/test_saturation.py
similarity index 100%
rename from test/blocks/operators/test_saturation.py
rename to tests/blocks/operators/test_saturation.py
diff --git a/test/blocks/operators/test_sum.py b/tests/blocks/operators/test_sum.py
similarity index 100%
rename from test/blocks/operators/test_sum.py
rename to tests/blocks/operators/test_sum.py
diff --git a/test/blocks/operators/test_zero_order_hold.py b/tests/blocks/operators/test_zero_order_hold.py
similarity index 100%
rename from test/blocks/operators/test_zero_order_hold.py
rename to tests/blocks/operators/test_zero_order_hold.py
diff --git a/test/blocks/sources/test_chirp.py b/tests/blocks/sources/test_chirp.py
similarity index 100%
rename from test/blocks/sources/test_chirp.py
rename to tests/blocks/sources/test_chirp.py
diff --git a/test/blocks/sources/test_constant.py b/tests/blocks/sources/test_constant.py
similarity index 100%
rename from test/blocks/sources/test_constant.py
rename to tests/blocks/sources/test_constant.py
diff --git a/test/blocks/sources/test_file_source.py b/tests/blocks/sources/test_file_source.py
similarity index 100%
rename from test/blocks/sources/test_file_source.py
rename to tests/blocks/sources/test_file_source.py
diff --git a/tests/blocks/sources/test_function_source.py b/tests/blocks/sources/test_function_source.py
new file mode 100644
index 0000000..8bdbc2f
--- /dev/null
+++ b/tests/blocks/sources/test_function_source.py
@@ -0,0 +1,87 @@
+import numpy as np
+import pytest
+
+from pySimBlocks.blocks.sources.function_source import FunctionSource
+
+
+def test_function_source_scalar_output():
+ def f(t, dt):
+ return 2.0 * t + dt
+
+ src = FunctionSource(name="f", function=f)
+ src.initialize(0.0)
+ assert np.allclose(src.outputs["out"], [[0.0]])
+
+ src.output_update(1.0, 0.1)
+ assert np.allclose(src.outputs["out"], [[2.1]])
+
+
+def test_function_source_vector_output_normalized_to_column():
+ def f(t, dt):
+ return np.array([t, t + dt])
+
+ src = FunctionSource(name="f", function=f)
+ src.initialize(0.0)
+ src.output_update(0.2, 0.1)
+
+ assert src.outputs["out"].shape == (2, 1)
+ assert np.allclose(src.outputs["out"], [[0.2], [0.3]])
+
+
+def test_function_source_signature_mismatch_raises():
+ def f(t, dt, u):
+ return np.array([[u]])
+
+ src = FunctionSource(name="f", function=f)
+ with pytest.raises(ValueError):
+ src.initialize(0.0)
+
+
+def test_function_source_function_error_is_wrapped():
+ def f(t, dt):
+ raise RuntimeError("boom")
+
+ src = FunctionSource(name="f", function=f)
+ with pytest.raises(RuntimeError) as err:
+ src.initialize(0.0)
+
+ assert "function call error" in str(err.value).lower()
+
+
+def test_function_source_output_shape_change_raises():
+ def f(t, dt):
+ if t < 0.1:
+ return np.array([[1.0]])
+ return np.array([[1.0, 2.0]])
+
+ src = FunctionSource(name="f", function=f)
+ src.initialize(0.0)
+
+ with pytest.raises(ValueError) as err:
+ src.output_update(0.1, 0.1)
+
+ assert "shape changed" in str(err.value).lower()
+
+
+def test_function_source_adapt_params_loads_function(tmp_path):
+ py_file = tmp_path / "my_function.py"
+ py_file.write_text(
+ "def my_source(t, dt):\n"
+ " return [[t + dt]]\n",
+ encoding="utf-8",
+ )
+
+ adapted = FunctionSource.adapt_params(
+ {"file_path": "my_function.py", "function_name": "my_source"},
+ params_dir=tmp_path,
+ )
+ src = FunctionSource(name="f", **adapted)
+
+ src.initialize(0.0)
+ src.output_update(0.2, 0.1)
+ assert np.allclose(src.outputs["out"], [[0.3]])
+
+
+def test_function_source_adapt_params_missing_key_raises():
+ with pytest.raises(ValueError):
+ FunctionSource.adapt_params({"file_path": "foo.py"}, params_dir=None)
diff --git a/test/blocks/sources/test_ramp.py b/tests/blocks/sources/test_ramp.py
similarity index 100%
rename from test/blocks/sources/test_ramp.py
rename to tests/blocks/sources/test_ramp.py
diff --git a/test/blocks/sources/test_sinusoidal.py b/tests/blocks/sources/test_sinusoidal.py
similarity index 100%
rename from test/blocks/sources/test_sinusoidal.py
rename to tests/blocks/sources/test_sinusoidal.py
diff --git a/test/blocks/sources/test_step.py b/tests/blocks/sources/test_step.py
similarity index 100%
rename from test/blocks/sources/test_step.py
rename to tests/blocks/sources/test_step.py
diff --git a/test/blocks/sources/test_white_noise.py b/tests/blocks/sources/test_white_noise.py
similarity index 100%
rename from test/blocks/sources/test_white_noise.py
rename to tests/blocks/sources/test_white_noise.py
diff --git a/test/blocks/systems/test_linear_state_space.py b/tests/blocks/systems/test_linear_state_space.py
similarity index 100%
rename from test/blocks/systems/test_linear_state_space.py
rename to tests/blocks/systems/test_linear_state_space.py
diff --git a/test/blocks/systems/test_polytopic_state_space.py b/tests/blocks/systems/test_polytopic_state_space.py
similarity index 100%
rename from test/blocks/systems/test_polytopic_state_space.py
rename to tests/blocks/systems/test_polytopic_state_space.py
diff --git a/test/core/test_core_errors_and_determinism.py b/tests/core/test_core_errors_and_determinism.py
similarity index 100%
rename from test/core/test_core_errors_and_determinism.py
rename to tests/core/test_core_errors_and_determinism.py
diff --git a/test/core/test_core_multirate_tasks.py b/tests/core/test_core_multirate_tasks.py
similarity index 100%
rename from test/core/test_core_multirate_tasks.py
rename to tests/core/test_core_multirate_tasks.py
diff --git a/test/core/test_core_two_phase_and_propagation.py b/tests/core/test_core_two_phase_and_propagation.py
similarity index 100%
rename from test/core/test_core_two_phase_and_propagation.py
rename to tests/core/test_core_two_phase_and_propagation.py
diff --git a/test/gui/main_window_open_test.py b/tests/gui/main_window_open_test.py
similarity index 100%
rename from test/gui/main_window_open_test.py
rename to tests/gui/main_window_open_test.py
diff --git a/test/gui/test_block_meta_param_cache.py b/tests/gui/test_block_meta_param_cache.py
similarity index 100%
rename from test/gui/test_block_meta_param_cache.py
rename to tests/gui/test_block_meta_param_cache.py
diff --git a/test/gui/test_file_source_meta.py b/tests/gui/test_file_source_meta.py
similarity index 100%
rename from test/gui/test_file_source_meta.py
rename to tests/gui/test_file_source_meta.py
diff --git a/tests/gui/test_function_source_meta.py b/tests/gui/test_function_source_meta.py
new file mode 100644
index 0000000..ff4c8dd
--- /dev/null
+++ b/tests/gui/test_function_source_meta.py
@@ -0,0 +1,16 @@
+from pySimBlocks.gui.blocks.sources.function_source import FunctionSourceMeta
+
+
+def test_function_source_meta_definition():
+ meta = FunctionSourceMeta()
+
+ assert meta.category == "sources"
+ assert meta.type == "function_source"
+ assert [p.name for p in meta.parameters] == [
+ "file_path",
+ "function_name",
+ "sample_time",
+ ]
+ assert len(meta.inputs) == 0
+ assert len(meta.outputs) == 1
+ assert meta.outputs[0].name == "out"
diff --git a/test/regression/data/simulink/dc_motor.npz b/tests/regression/data/simulink/dc_motor.npz
similarity index 100%
rename from test/regression/data/simulink/dc_motor.npz
rename to tests/regression/data/simulink/dc_motor.npz
diff --git a/test/regression/test_simulink_dc_motor.py b/tests/regression/test_simulink_dc_motor.py
similarity index 100%
rename from test/regression/test_simulink_dc_motor.py
rename to tests/regression/test_simulink_dc_motor.py