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. -![Alt Text](./docs/User_Guide/images/quick_example.png) +![Damped oscillator simulation](./docs/User_Guide/images/quick_example.png) + +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: + +![GUI Example](./docs/User_Guide/images/gui_example.png) + 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