From 614d298327dc8e79b969631137c315392fd293cb Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Feb 2026 12:00:33 +0100 Subject: [PATCH 1/3] docs(README): add gui quick example --- README.md | 38 ++++++++++++--------- docs/User_Guide/images/gui_example.png | Bin 0 -> 35473 bytes examples/quick_start/gui/layout.yaml | 41 +++++++++++++++++++++++ examples/quick_start/gui/model.yaml | 23 +++++++++++++ examples/quick_start/gui/parameters.yaml | 27 +++++++++++++++ examples/quick_start/gui/run.py | 23 +++++++++++++ examples/quick_start/oscillator.py | 41 +++++++++++++++++++++++ 7 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 docs/User_Guide/images/gui_example.png create mode 100644 examples/quick_start/gui/layout.yaml create mode 100644 examples/quick_start/gui/model.yaml create mode 100644 examples/quick_start/gui/parameters.yaml create mode 100644 examples/quick_start/gui/run.py create mode 100644 examples/quick_start/oscillator.py 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 0000000000000000000000000000000000000000..5bf21ea78b5ceeaea7175f31abbb61b562f14bbe GIT binary patch literal 35473 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 literal 0 HcmV?d00001 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) + From d35d32c28e15d8c06aeeea1c7f04dd2048db3aed Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Feb 2026 20:32:15 +0100 Subject: [PATCH 2/3] chore(test): rename test -> tests --- {test => tests}/blocks/controllers/test_pid.py | 2 +- .../blocks/controllers/test_state_feedback.py | 0 {test => tests}/blocks/observers/test_luenberger.py | 2 +- .../blocks/operators/test_algebraic_function.py | 0 {test => tests}/blocks/operators/test_dead_zone.py | 0 {test => tests}/blocks/operators/test_delay.py | 0 {test => tests}/blocks/operators/test_demux.py | 0 .../blocks/operators/test_discrete_derivator.py | 0 .../blocks/operators/test_discrete_integrator.py | 0 {test => tests}/blocks/operators/test_gain.py | 0 {test => tests}/blocks/operators/test_mux.py | 0 {test => tests}/blocks/operators/test_product.py | 0 .../blocks/operators/test_rate_limiter.py | 0 {test => tests}/blocks/operators/test_saturation.py | 0 {test => tests}/blocks/operators/test_sum.py | 0 .../blocks/operators/test_zero_order_hold.py | 0 {test => tests}/blocks/sources/test_chirp.py | 0 {test => tests}/blocks/sources/test_constant.py | 0 {test => tests}/blocks/sources/test_file_source.py | 0 {test => tests}/blocks/sources/test_ramp.py | 0 {test => tests}/blocks/sources/test_sinusoidal.py | 0 {test => tests}/blocks/sources/test_step.py | 0 {test => tests}/blocks/sources/test_white_noise.py | 0 .../blocks/systems/test_linear_state_space.py | 0 .../blocks/systems/test_polytopic_state_space.py | 0 .../core/test_core_errors_and_determinism.py | 0 {test => tests}/core/test_core_multirate_tasks.py | 0 .../core/test_core_two_phase_and_propagation.py | 0 {test => tests}/gui/main_window_open_test.py | 0 {test => tests}/gui/test_block_meta_param_cache.py | 0 {test => tests}/gui/test_file_source_meta.py | 0 .../regression/data/simulink/dc_motor.npz | Bin .../regression/test_simulink_dc_motor.py | 0 33 files changed, 2 insertions(+), 2 deletions(-) rename {test => tests}/blocks/controllers/test_pid.py (99%) rename {test => tests}/blocks/controllers/test_state_feedback.py (100%) rename {test => tests}/blocks/observers/test_luenberger.py (99%) rename {test => tests}/blocks/operators/test_algebraic_function.py (100%) rename {test => tests}/blocks/operators/test_dead_zone.py (100%) rename {test => tests}/blocks/operators/test_delay.py (100%) rename {test => tests}/blocks/operators/test_demux.py (100%) rename {test => tests}/blocks/operators/test_discrete_derivator.py (100%) rename {test => tests}/blocks/operators/test_discrete_integrator.py (100%) rename {test => tests}/blocks/operators/test_gain.py (100%) rename {test => tests}/blocks/operators/test_mux.py (100%) rename {test => tests}/blocks/operators/test_product.py (100%) rename {test => tests}/blocks/operators/test_rate_limiter.py (100%) rename {test => tests}/blocks/operators/test_saturation.py (100%) rename {test => tests}/blocks/operators/test_sum.py (100%) rename {test => tests}/blocks/operators/test_zero_order_hold.py (100%) rename {test => tests}/blocks/sources/test_chirp.py (100%) rename {test => tests}/blocks/sources/test_constant.py (100%) rename {test => tests}/blocks/sources/test_file_source.py (100%) rename {test => tests}/blocks/sources/test_ramp.py (100%) rename {test => tests}/blocks/sources/test_sinusoidal.py (100%) rename {test => tests}/blocks/sources/test_step.py (100%) rename {test => tests}/blocks/sources/test_white_noise.py (100%) rename {test => tests}/blocks/systems/test_linear_state_space.py (100%) rename {test => tests}/blocks/systems/test_polytopic_state_space.py (100%) rename {test => tests}/core/test_core_errors_and_determinism.py (100%) rename {test => tests}/core/test_core_multirate_tasks.py (100%) rename {test => tests}/core/test_core_two_phase_and_propagation.py (100%) rename {test => tests}/gui/main_window_open_test.py (100%) rename {test => tests}/gui/test_block_meta_param_cache.py (100%) rename {test => tests}/gui/test_file_source_meta.py (100%) rename {test => tests}/regression/data/simulink/dc_motor.npz (100%) rename {test => tests}/regression/test_simulink_dc_motor.py (100%) 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/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/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 From c491f56efa3bb669521c7140bc32835109907ab0 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Feb 2026 20:47:31 +0100 Subject: [PATCH 3/3] feat(core): add function source blocks --- pySimBlocks/blocks/__init__.py | 10 +- pySimBlocks/blocks/sources/__init__.py | 2 + pySimBlocks/blocks/sources/function_source.py | 170 ++++++++++++++++++ .../docs/blocks/sources/function_source.md | 52 ++++++ .../gui/blocks/sources/function_source.py | 163 +++++++++++++++++ .../project/pySimBlocks_blocks_index.yaml | 3 + tests/blocks/sources/test_function_source.py | 87 +++++++++ tests/gui/test_function_source_meta.py | 16 ++ 8 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 pySimBlocks/blocks/sources/function_source.py create mode 100644 pySimBlocks/docs/blocks/sources/function_source.md create mode 100644 pySimBlocks/gui/blocks/sources/function_source.py create mode 100644 tests/blocks/sources/test_function_source.py create mode 100644 tests/gui/test_function_source_meta.py 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/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/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"