From 723eef336b85476ff1a942e0414c3870ac951819 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 16 Mar 2026 23:58:24 +0100 Subject: [PATCH 01/19] fix(docs): index duplication blocks core --- docs/source/_ext/api_generator.py | 1 - docs/source/conf.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/source/_ext/api_generator.py b/docs/source/_ext/api_generator.py index 3b72911..804c0d6 100644 --- a/docs/source/_ext/api_generator.py +++ b/docs/source/_ext/api_generator.py @@ -147,7 +147,6 @@ def _module_page(module: str) -> str: content = _title(title) content += f".. automodule:: {module}\n" content += " :members:\n" - content += " :undoc-members:\n" content += " :show-inheritance:\n" content += " :member-order: bysource\n" content += "\n" diff --git a/docs/source/conf.py b/docs/source/conf.py index 6ee579b..c1ca9c8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -5,6 +5,9 @@ import types import zipfile from pathlib import Path +from unittest.mock import MagicMock + +sys.modules["qpsolvers"] = MagicMock() ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.insert(0, ROOT) @@ -76,7 +79,7 @@ def _noop(*args, **kwargs): } autodoc_member_order = "bysource" -autodoc_mock_imports = [] +autodoc_mock_imports = ["qpsolvers"] autodoc_type_aliases = { "ArrayLike": "ArrayLike", } From 4fbcd3ada31e6a34206f1e26a656e084927a92c5 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 17 Mar 2026 00:35:02 +0100 Subject: [PATCH 02/19] fix(docs): readme docs links --- README.md | 46 ++++++-------------------- docs/source/user_guide/installation.md | 7 ++-- 2 files changed, 15 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 92708cc..748c649 100644 --- a/README.md +++ b/README.md @@ -3,55 +3,31 @@ [![PyPI version](https://img.shields.io/pypi/v/pySimBlocks.svg)](https://pypi.org/project/pySimBlocks/) [![Python](https://img.shields.io/pypi/pyversions/pySimBlocks.svg)](https://pypi.org/project/pySimBlocks/) [![License](https://img.shields.io/github/license/AlessandriniAntoine/pySimBlocks)](./LICENSE.md) +[![Documentation](https://readthedocs.org/projects/pysimblocks/badge/?version=latest)](https://pysimblocks.readthedocs.io) --- A deterministic block-diagram simulation framework for discrete-time modeling, co-simulation and research prototyping in Python. -pySimBlocks allows you to build, configure, and execute discrete-time systems +pySimBlocks allows you to build, configure, and execute discrete-time systems using either: - A pure Python API - A graphical editor (PySide6) -- YAML project configuration +- YAML project configuration with exportable Python runner (`run.py`) - Optional SOFA and hardware integration ![pySimBlocks graphical editor](https://raw.githubusercontent.com/AlessandriniAntoine/pySimBlocks/main/docs/source/images/user_guide/gui_example.png) -## Features - -- Block-based modeling (Simulink-like) -- Deterministic discrete-time simulation engine -- PySide6 graphical editor -- YAML-based project serialization -- Exportable Python runner (`run.py`) -- Extensible block architecture - ## Installation -### From PyPI - -Install the latest stable version from PyPI using pip: -``` +```bash pip install pySimBlocks ``` -### From GitHub - -Install directly from GitHub using pip: -``` -pip install git+https://github.com/AlessandriniAntoine/pySimBlocks -``` - -### Locally - -Clone the repository and install locally: -``` -git clone https://github.com/AlessandriniAntoine/pySimBlocks.git -cd pySimBlocks -pip install . -``` +Full documentation — user guide, tutorials, and API reference — is available on +[**Read the Docs**](https://pysimblocks.readthedocs.io). ## Getting Started @@ -126,14 +102,14 @@ The quick-start GUI project is stored in a single #### Tutorials -Three step-by-step tutorials are available detailed in the -[guide](./docs/source/user_guide/tutorials.md): +Three step-by-step tutorials are available in the +[documentation](https://pysimblocks.readthedocs.io/en/latest/user_guide/tutorials/index.html): | | Tutorial | Description | |---|---|---| - | 1 | [Python API](./docs/source/user_guide/tutorials/tutorial_1_python.md) | Build a closed-loop PI control system in pure Python | - | 2 | [GUI](./docs/source/user_guide/tutorials/tutorial_2_gui.md) | Build the same system visually with the graphical editor | - | 3 | [SOFA](./docs/source/user_guide/tutorials/tutorial_3_sofa.md) | Replace the plant with a SOFA physics simulation | + | 1 | [Python API](https://pysimblocks.readthedocs.io/en/latest/user_guide/tutorials/tutorial_1_python.html) | Build a closed-loop PI control system in pure Python | + | 2 | [GUI](https://pysimblocks.readthedocs.io/en/latest/user_guide/tutorials/tutorial_2_gui.html) | Build the same system visually with the graphical editor | + | 3 | [SOFA](https://pysimblocks.readthedocs.io/en/latest/user_guide/tutorials/tutorial_3_sofa.html) | Replace the plant with a SOFA physics simulation | #### Other Examples diff --git a/docs/source/user_guide/installation.md b/docs/source/user_guide/installation.md index 042fd72..e66e70c 100644 --- a/docs/source/user_guide/installation.md +++ b/docs/source/user_guide/installation.md @@ -22,15 +22,16 @@ cd pySimBlocks pip install . ``` -## Documentation build - -To build the documentation locally from the `docs` directory: +To also build the documentation locally: ```bash pip install pySimBlocks[docs] +cd docs make html ``` +The HTML output will be in `docs/_build/html/`. + ## Optional dependencies ### Examples From 8fb683832b50c809bfe4c5cb5c3c7a3f11aec180 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 17 Mar 2026 16:56:59 +0100 Subject: [PATCH 03/19] fix(docs): specify data shape file source block --- pySimBlocks/blocks/sources/file_source.py | 8 ++++++++ pySimBlocks/docs/blocks/sources/file_source.md | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pySimBlocks/blocks/sources/file_source.py b/pySimBlocks/blocks/sources/file_source.py index c4519ae..af70290 100644 --- a/pySimBlocks/blocks/sources/file_source.py +++ b/pySimBlocks/blocks/sources/file_source.py @@ -34,6 +34,14 @@ class FileSource(BlockSource): the data is reached, the block either restarts from the first sample (``repeat=True``) or outputs zeros. + Expected data shapes: + + - ``.npz`` / ``.npy``: 1D ``(N,)`` treated as ``(N, 1)``, or 2D ``(N, n)`` + where N is the number of samples and n the signal dimension. Each step + outputs a ``(n, 1)`` column vector. + - ``.csv``: a single column is selected by ``key``, always producing shape + ``(N, 1)``. Output per step is ``(1, 1)``. + Alternatively, when ``use_time=True``, the output is selected by looking up the closest past timestamp in a time column bundled with the file, rather than advancing by index. diff --git a/pySimBlocks/docs/blocks/sources/file_source.md b/pySimBlocks/docs/blocks/sources/file_source.md index eadde2a..58b0d76 100644 --- a/pySimBlocks/docs/blocks/sources/file_source.md +++ b/pySimBlocks/docs/blocks/sources/file_source.md @@ -40,9 +40,8 @@ None. ## Notes - File format is inferred from `file_path` extension (`.npz`, `.npy`, `.csv`). -- `npz`: loaded array must be 1D or 2D. -- `npy`: loaded array must be 1D or 2D. -- `csv`: `key` selects one named numeric column, producing shape `(1,1)` at each step. +- `npz` / `npy`: array must be 1D `(N,)` or 2D `(N, n)` — N samples, n signal dimension. Output per step: `(n, 1)`. +- `csv`: `key` selects a single named column, always `(N, 1)`. Output per step: `(1, 1)`. - With `use_time=true`, `time` must exist and be strictly increasing. - `npz`: requires key `time`. - `csv`: requires column `time`. From 0c615360ffcf7f4221f24b7ddffb01f26f8dbc5b Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Mar 2026 08:39:38 +0100 Subject: [PATCH 04/19] fix(docs): download link on rtd --- .../user_guide/tutorials/tutorial_1_python.md | 11 ++++------- .../user_guide/tutorials/tutorial_2_gui.md | 14 +++++--------- .../user_guide/tutorials/tutorial_3_sofa.md | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/source/user_guide/tutorials/tutorial_1_python.md b/docs/source/user_guide/tutorials/tutorial_1_python.md index 9868cef..7ac2e9a 100644 --- a/docs/source/user_guide/tutorials/tutorial_1_python.md +++ b/docs/source/user_guide/tutorials/tutorial_1_python.md @@ -12,17 +12,14 @@ This tutorial introduces the core concepts of `pySimBlocks`: By the end of this tutorial, you will be able to build and simulate your own block-based model in Python. -## Example Files +## Example File -The full example lives in `examples/tutorials/tutorial_1_python/`. -The main file is: +You can download or view the main example file here: - [`main.py`](../../../../examples/tutorials/tutorial_1_python/main.py) -If you are reading the published documentation, you can download the example -file directly: - -{download}`Download main.py <../../../../examples/tutorials/tutorial_1_python/main.py>` +If you have cloned the repository, the full example lives in +`examples/tutorials/tutorial_1_python/`. ## System Description diff --git a/docs/source/user_guide/tutorials/tutorial_2_gui.md b/docs/source/user_guide/tutorials/tutorial_2_gui.md index efb2728..7e4b03f 100644 --- a/docs/source/user_guide/tutorials/tutorial_2_gui.md +++ b/docs/source/user_guide/tutorials/tutorial_2_gui.md @@ -160,20 +160,16 @@ the simulation from the command line: python run.py ``` -## Reference Project +## Example File -If you want to compare your result with a completed version, the full reference -project lives in `examples/tutorials/tutorial_2_gui/`. -The main file is: +If you want to compare your result with a completed version, you can download +or view the project files here: - [`project.yaml`](../../../../examples/tutorials/tutorial_2_gui/project.yaml) - [`run.py`](../../../../examples/tutorials/tutorial_2_gui/run.py) -If you are reading the published documentation, you can download the completed -project file directly: - -{download}`Download project.yaml <../../../../examples/tutorials/tutorial_2_gui/project.yaml>` -{download}`Download run.py <../../../../examples/tutorials/tutorial_2_gui/run.py>` +If you have cloned the repository, the full reference project lives in +`examples/tutorials/tutorial_2_gui/`. ## Comparison with the Python Version diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md index fd96c1d..9459848 100644 --- a/docs/source/user_guide/tutorials/tutorial_3_sofa.md +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -16,21 +16,22 @@ The objective is to: By the end of this tutorial, you will be able to connect a `pySimBlocks` control loop to a SOFA simulation. -## Required Files +## Example Files -The full example lives in `examples/tutorials/tutorial_3_sofa/`. -The main files are: +You can download or view the main project files here: -- [`finger/Finger.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/Finger.py): the SOFA scene file -- [`finger/FingerController.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py): Completed SOFA controller -- [`project.yaml`](../../../../examples/tutorials/tutorial_3_sofa/project.yaml): Completed GUI project file. +- [`finger/Finger.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/Finger.py): SOFA scene +- [`finger/FingerController.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py): SOFA controller +- [`project.yaml`](../../../../examples/tutorials/tutorial_3_sofa/project.yaml): GUI project file -If you are reading the published documentation, you can download the complete -working example as an archive. It includes the GUI project, the SOFA scene, the -controller, and the additional files required by the scene: +To run the scene you also need the mesh files and additional assets bundled in +the complete archive: {download}`Download tutorial_3_sofa.zip <../../_static/downloads/tutorial_3_sofa.zip>` +If you have cloned the repository, the full example lives in +`examples/tutorials/tutorial_3_sofa/`. + ## System Description We build the same closed-loop structure as in the previous tutorials: From 843c3f945c9120ec7dddeca0b7a363a8d23e5f8d Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Mar 2026 09:03:44 +0100 Subject: [PATCH 05/19] fix(docs): starting point tuto sofa --- .../user_guide/tutorials/tutorial_3_sofa.md | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md index 9459848..3148476 100644 --- a/docs/source/user_guide/tutorials/tutorial_3_sofa.md +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -16,22 +16,25 @@ The objective is to: By the end of this tutorial, you will be able to connect a `pySimBlocks` control loop to a SOFA simulation. -## Example Files +## Required Files -You can download or view the main project files here: +This tutorial uses a SOFA scene and mesh files that are provided for you. +Download the archive below — it contains everything you need: -- [`finger/Finger.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/Finger.py): SOFA scene -- [`finger/FingerController.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py): SOFA controller -- [`project.yaml`](../../../../examples/tutorials/tutorial_3_sofa/project.yaml): GUI project file - -To run the scene you also need the mesh files and additional assets bundled in -the complete archive: +- `finger/Finger.py` — the SOFA scene +- `finger/mesh/` — mesh assets required by the scene +- `finger/FingerController.py` — the SOFA controller (also shown in full below) +- `project.yaml` — the tutorial 2 project as a starting point +- `project_solution.yaml` — the completed reference to compare against {download}`Download tutorial_3_sofa.zip <../../_static/downloads/tutorial_3_sofa.zip>` -If you have cloned the repository, the full example lives in +If you have cloned the repository, the files are already in `examples/tutorials/tutorial_3_sofa/`. +Extract the archive so that `finger/` and `project.yaml` sit in the same +folder. + ## System Description We build the same closed-loop structure as in the previous tutorials: @@ -95,6 +98,7 @@ Then verify that `runSofa` is available: - Linux and macOS: `$SOFA_ROOT/bin/runSofa` - Windows: `$env:SOFA_ROOT\bin\runSofa.exe` + ## SOFA Controller Contract To exchange data between the diagram and the SOFA scene, subclass @@ -186,3 +190,4 @@ Experiment with the coupled model to better understand the workflow: - Change the reference and observe the fingertip response - Run the same project with both execution modes - If available, use the live SOFA sliders and plots for real-time tuning + From 0aa419f61ec4de89bdbd62284929c2a5fd2b9922 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Mar 2026 09:03:53 +0100 Subject: [PATCH 06/19] fix(docs): starting point tuto sofa --- .../tutorials/tutorial_3_sofa/project.yaml | 99 ++++++++--------- .../tutorial_3_sofa/project_solution.yaml | 100 ++++++++++++++++++ 2 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 examples/tutorials/tutorial_3_sofa/project_solution.yaml diff --git a/examples/tutorials/tutorial_3_sofa/project.yaml b/examples/tutorials/tutorial_3_sofa/project.yaml index 9ca39a6..188c5e1 100644 --- a/examples/tutorials/tutorial_3_sofa/project.yaml +++ b/examples/tutorials/tutorial_3_sofa/project.yaml @@ -1,100 +1,95 @@ schema_version: 1 project: - name: tutorial_3_sofa + name: tutorial_2_gui metadata: created_by: pySimBlocks created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.01 - T: 10.0 + T: 5.0 solver: fixed logging: + - system.outputs.y - ref.outputs.out - - Sofa.outputs.measure + - PID.outputs.u plots: - title: Ref vs Output signals: + - system.outputs.y - ref.outputs.out - - Sofa.outputs.measure + - title: Command + signals: + - PID.outputs.u diagram: blocks: - - name: ref - category: sources - type: constant - parameters: - value: [[1.0]] - - name: error - category: operators - type: sum + - name: system + category: systems + type: linear_state_space parameters: - signs: +- + A: [[0.9]] + B: [[0.5]] + C: [[1.0]] + x0: 0.0 - name: PID category: controllers type: pid parameters: controller: PI - Kp: 0.2 - Ki: 1.3 - - name: Sofa - category: systems - type: sofa_plant + Kp: 0.5 + Ki: 2.0 + - name: error + category: operators + type: sum + parameters: + signs: +- + - name: ref + category: sources + type: step parameters: - scene_file: finger/Finger.py - input_keys: - - cable - output_keys: - - measure - slider_params: - ref.value: - - -10.0 - - 50.0 - PID.Kp: - - 0.01 - - 3.0 - PID.Ki: - - 0.01 - - 3.0 + value_before: [[0.0]] + value_after: [[1.0]] + start_time: 1.0 connections: - name: c1 - ports: - - error.out - - PID.e - - name: c2 ports: - ref.out - error.in1 + - name: c2 + ports: + - error.out + - PID.e - name: c3 ports: - PID.u - - Sofa.cable + - system.u - name: c4 ports: - - Sofa.measure + - system.y - error.in2 gui: layout: blocks: - ref: - x: -480.0 - y: -45.0 + system: + x: -15.0 + y: -600.0 orientation: normal width: 120.0 height: 60.0 - error: - x: -265.0 - y: -35.0 + PID: + x: -220.0 + y: -600.0 orientation: normal width: 120.0 height: 60.0 - PID: - x: -95.0 - y: -35.0 + error: + x: -415.0 + y: -600.0 orientation: normal width: 120.0 height: 60.0 - Sofa: - x: 95.0 - y: -35.0 + ref: + x: -595.0 + y: -610.0 orientation: normal - width: 155.0 + width: 120.0 height: 60.0 diff --git a/examples/tutorials/tutorial_3_sofa/project_solution.yaml b/examples/tutorials/tutorial_3_sofa/project_solution.yaml new file mode 100644 index 0000000..9ca39a6 --- /dev/null +++ b/examples/tutorials/tutorial_3_sofa/project_solution.yaml @@ -0,0 +1,100 @@ +schema_version: 1 +project: + name: tutorial_3_sofa + metadata: + created_by: pySimBlocks + created_at: '2026-02-18T00:00:00Z' +simulation: + dt: 0.01 + T: 10.0 + solver: fixed + logging: + - ref.outputs.out + - Sofa.outputs.measure + plots: + - title: Ref vs Output + signals: + - ref.outputs.out + - Sofa.outputs.measure +diagram: + blocks: + - name: ref + category: sources + type: constant + parameters: + value: [[1.0]] + - name: error + category: operators + type: sum + parameters: + signs: +- + - name: PID + category: controllers + type: pid + parameters: + controller: PI + Kp: 0.2 + Ki: 1.3 + - name: Sofa + category: systems + type: sofa_plant + parameters: + scene_file: finger/Finger.py + input_keys: + - cable + output_keys: + - measure + slider_params: + ref.value: + - -10.0 + - 50.0 + PID.Kp: + - 0.01 + - 3.0 + PID.Ki: + - 0.01 + - 3.0 + connections: + - name: c1 + ports: + - error.out + - PID.e + - name: c2 + ports: + - ref.out + - error.in1 + - name: c3 + ports: + - PID.u + - Sofa.cable + - name: c4 + ports: + - Sofa.measure + - error.in2 +gui: + layout: + blocks: + ref: + x: -480.0 + y: -45.0 + orientation: normal + width: 120.0 + height: 60.0 + error: + x: -265.0 + y: -35.0 + orientation: normal + width: 120.0 + height: 60.0 + PID: + x: -95.0 + y: -35.0 + orientation: normal + width: 120.0 + height: 60.0 + Sofa: + x: 95.0 + y: -35.0 + orientation: normal + width: 155.0 + height: 60.0 From 740a98712519efb4d0b34bc0db8e2c8e9f77e939 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Mar 2026 09:07:12 +0100 Subject: [PATCH 07/19] fix(docs): unify download linl --- docs/source/user_guide/quick_start.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/quick_start.md b/docs/source/user_guide/quick_start.md index 031fe32..67edebd 100644 --- a/docs/source/user_guide/quick_start.md +++ b/docs/source/user_guide/quick_start.md @@ -35,7 +35,9 @@ pysimblocks gui examples/quick_start/gui ## Downloads -You can download the quick-start example files here: +You can download or view the quick-start example files here: -- {download}`filter.py <../../../examples/quick_start/filter.py>` -- {download}`project.yaml <../../../examples/quick_start/gui/project.yaml>` +- [`filter.py`](../../../examples/quick_start/filter.py) +- [`project.yaml`](../../../examples/quick_start/gui/project.yaml) + +If you have cloned the repository, the files are in `examples/quick_start/`. From 5d15b0968b1c1725cf545ef9a8afa9973d1d0bae Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 18 Mar 2026 09:15:46 +0100 Subject: [PATCH 08/19] feat(docs): add concepts structure --- docs/source/concepts/block_model.md | 3 +++ docs/source/concepts/execution_order.md | 3 +++ docs/source/concepts/glossary.md | 3 +++ docs/source/concepts/index.rst | 13 +++++++++++++ docs/source/concepts/simulation_lifecycle.md | 3 +++ docs/source/index.rst | 1 + 6 files changed, 26 insertions(+) create mode 100644 docs/source/concepts/block_model.md create mode 100644 docs/source/concepts/execution_order.md create mode 100644 docs/source/concepts/glossary.md create mode 100644 docs/source/concepts/index.rst create mode 100644 docs/source/concepts/simulation_lifecycle.md diff --git a/docs/source/concepts/block_model.md b/docs/source/concepts/block_model.md new file mode 100644 index 0000000..ae3d69e --- /dev/null +++ b/docs/source/concepts/block_model.md @@ -0,0 +1,3 @@ +# Block Model + +This page describes how a block is defined in pySimBlocks — its inputs, outputs, parameters, and internal state — and how blocks are connected to form a model. diff --git a/docs/source/concepts/execution_order.md b/docs/source/concepts/execution_order.md new file mode 100644 index 0000000..eb75d56 --- /dev/null +++ b/docs/source/concepts/execution_order.md @@ -0,0 +1,3 @@ +# Execution Order and Algebraic Loops + +This page explains how pySimBlocks determines the order in which blocks are executed each time step, and what happens when a dependency cycle (algebraic loop) is detected. diff --git a/docs/source/concepts/glossary.md b/docs/source/concepts/glossary.md new file mode 100644 index 0000000..5727473 --- /dev/null +++ b/docs/source/concepts/glossary.md @@ -0,0 +1,3 @@ +# Glossary + +This page defines the key terms used throughout the pySimBlocks documentation, such as signal, sample time, direct feedthrough, and algebraic loop. diff --git a/docs/source/concepts/index.rst b/docs/source/concepts/index.rst new file mode 100644 index 0000000..0a60320 --- /dev/null +++ b/docs/source/concepts/index.rst @@ -0,0 +1,13 @@ +Concepts +======== + +This section explains the core ideas behind pySimBlocks — what the framework +does, how it thinks about blocks and signals, and why it behaves the way it does. + +.. toctree:: + :maxdepth: 1 + + simulation_lifecycle + block_model + execution_order + glossary diff --git a/docs/source/concepts/simulation_lifecycle.md b/docs/source/concepts/simulation_lifecycle.md new file mode 100644 index 0000000..c2253e3 --- /dev/null +++ b/docs/source/concepts/simulation_lifecycle.md @@ -0,0 +1,3 @@ +# Simulation Life Cycle + +This page describes the sequence of phases a simulation goes through from initialization to teardown, and what happens at each step. diff --git a/docs/source/index.rst b/docs/source/index.rst index 6dcc4e1..9cb4699 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -55,4 +55,5 @@ Documentation Overview :caption: Contents user_guide/index + concepts/index api/index From b34322a92b43ac3cc603803deb05f2b7c197c5e1 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 24 Mar 2026 21:42:00 +0100 Subject: [PATCH 09/19] fix(core): sofa time out in worker --- pySimBlocks/blocks/systems/sofa/sofa_plant.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/pySimBlocks/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/blocks/systems/sofa/sofa_plant.py index ce1a684..d82bea7 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_plant.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_plant.py @@ -250,10 +250,7 @@ def initialize(self, t0: float) -> None: ) self.process.start() - initial_outputs = self.conn.recv() - - if isinstance(initial_outputs, dict) and initial_outputs.get("cmd") == "error": - raise RuntimeError(initial_outputs["message"]) + initial_outputs = self._recv_or_raise() for k in self.output_keys: self.outputs[k] = initial_outputs[k] @@ -292,9 +289,7 @@ def state_update(self, t: float, dt: float) -> None: self.conn.send(msg) - outputs = self.conn.recv() - if isinstance(outputs, dict) and outputs.get("cmd") == "error": - raise RuntimeError(outputs["message"]) + outputs = self._recv_or_raise(timeout=5.) for k in self.output_keys: self.next_state[k] = outputs[k] @@ -321,6 +316,31 @@ def finalize(self) -> None: # Private methods # -------------------------------------------------------------------------- + def _recv_or_raise(self, timeout: float = 30.0) -> Any: + """Receive a message from the SOFA worker with timeout and crash detection. + + Args: + timeout: Maximum seconds to wait for a response. + + Returns: + The message received from the worker. + + Raises: + RuntimeError: If the worker times out, has died, or reports an error. + """ + if not self.conn.poll(timeout): + if not self.process.is_alive(): + raise RuntimeError( + f"[{self.name}] SOFA worker process died unexpectedly." + ) + raise RuntimeError( + f"[{self.name}] SOFA worker timed out after {timeout}s." + ) + result = self.conn.recv() + if isinstance(result, dict) and result.get("cmd") == "error": + raise RuntimeError(result["message"]) + return result + def __del__(self) -> None: """Attempt to stop the worker process on garbage collection.""" if self.conn: From bf8d5869de9c352bdb54443d9fbb0aeffd0f59a0 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 24 Mar 2026 22:44:09 +0100 Subject: [PATCH 10/19] feat(sofa): add dt consistency in sofaplant --- pySimBlocks/blocks/systems/sofa/sofa_plant.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/pySimBlocks/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/blocks/systems/sofa/sofa_plant.py index d82bea7..d37c09d 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_plant.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_plant.py @@ -29,7 +29,7 @@ from pySimBlocks.core.block import Block -def sofa_worker(conn, scene_file, input_keys, output_keys): +def sofa_worker(conn, scene_file, input_keys, output_keys, sample_time, block_name): """Worker function executed in a subprocess to run the SOFA simulation.""" import os import sys @@ -75,10 +75,24 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): conn.close() return - controller.SOFA_MASTER = False - Sofa.Simulation.initRoot(root) dt = float(root.dt.value) + ratio = sample_time / dt + if abs(ratio - round(ratio)) > 1e-9 or round(ratio) < 1: + conn.send({ + "cmd": "error", + "message": ( + f"[pySimBlocks] ERROR [{block_name}]: SofaPlant sample_time={sample_time}s " + f"is not a positive integer multiple of SOFA scene dt={dt}s " + f"(ratio={ratio:.6g})." + ) + }) + conn.close() + return + ratio = int(round(ratio)) + + controller.SOFA_MASTER = False + Sofa.Simulation.initRoot(root) while not controller.IS_READY: controller.prepare_scene() @@ -107,7 +121,8 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): controller.inputs[key] = val controller.set_inputs() - Sofa.Simulation.animate(root, dt) + for _ in range(ratio): + Sofa.Simulation.animate(root, dt) controller.get_outputs() outputs = {k: np.asarray(controller.outputs[k]).reshape(-1, 1) @@ -177,7 +192,7 @@ def __init__( for k in input_keys: self.inputs[k] = None - self.next_outputs = {} + self.next_outputs = {} for k in output_keys: self.outputs[k] = None self.state[k] = None @@ -246,7 +261,8 @@ def initialize(self, t0: float) -> None: self.process = Process( target=sofa_worker, - args=(child_conn, self.scene_file, self.input_keys, self.output_keys) + args=(child_conn, self.scene_file, self.input_keys, self.output_keys, + self._effective_sample_time, self.name) ) self.process.start() From 561c912896580b6683cce316a93871d59e1ca967 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 24 Mar 2026 23:40:55 +0100 Subject: [PATCH 11/19] feat(sofa): refactor(sofa): auto-resolve SOFA context in controller --- .../user_guide/tutorials/tutorial_3_sofa.md | 17 ++++++--- .../tutorial_3_sofa/finger/Finger.py | 2 +- .../finger/FingerController.py | 5 +-- .../blocks/systems/sofa/sofa_controller.py | 38 ++++++++++++------- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md index 3148476..cae224b 100644 --- a/docs/source/user_guide/tutorials/tutorial_3_sofa.md +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -106,15 +106,14 @@ To exchange data between the diagram and the SOFA scene, subclass Your controller must define: -- `self.project_yaml` -- `self.dt` -- `self.inputs` -- `self.outputs` +- `self.project_yaml` — path to the `project.yaml` file +- `self.inputs` — dict of signals received from pySimBlocks (keys must match `input_keys` on the block) +- `self.outputs` — dict of signals sent back to pySimBlocks (keys must match `output_keys` on the block) It must implement: -- `set_inputs()` -- `get_outputs()` +- `set_inputs()` — apply `self.inputs` values to the SOFA scene +- `get_outputs()` — read SOFA state and write results into `self.outputs` ### Example Controller @@ -145,6 +144,12 @@ Set the block parameters as follows: The key names must match the dictionaries declared in the SOFA controller. +> **Note:**: Time step constraint +> The SOFA scene dt (set in createScene via rootNode.dt) must be a positive integer divisor of the SofaPlant sample time. If no sample_time is set on the block, the global simulation dt is used. +> pySimBlocks enforces this at startup and raises an error if the constraint is not satisfied. +> Example: if rootNode.dt = 0.01 in your scene, valid block sample times are 0.01, 0.02, 0.05, etc. + + ## Execution Modes ### pySimBlocks as Master diff --git a/examples/tutorials/tutorial_3_sofa/finger/Finger.py b/examples/tutorials/tutorial_3_sofa/finger/Finger.py index 8024b2d..fd91125 100644 --- a/examples/tutorials/tutorial_3_sofa/finger/Finger.py +++ b/examples/tutorials/tutorial_3_sofa/finger/Finger.py @@ -114,7 +114,7 @@ def createScene(rootNode): # -------------------------------------------------------------------------- # Controller # -------------------------------------------------------------------------- - controller = FingerController(rootNode, cable.aCableActuator, finger.tetras) + controller = FingerController(cable.aCableActuator, finger.tetras) finger.addObject(controller) return rootNode, controller diff --git a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py index d7e06c0..e762914 100644 --- a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py +++ b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py @@ -9,14 +9,13 @@ class FingerController(SofaPysimBlocksController): - def __init__(self, root, actuator, mo, tip_index=121, name="FingerController"): - super().__init__(root, name=name) + def __init__(self, actuator, mo, tip_index=121, name="FingerController"): + super().__init__(name=name) self.project_yaml = str((BASE_DIR / '../project.yaml').resolve()) self.mo = mo self.actuator = actuator self.tip_index = tip_index - self.dt = root.dt.value self.verbose = False # Set to True to print debug information at each step # Inputs & outputs dictionaries diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py index 271e289..eb2b704 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_controller.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_controller.py @@ -58,22 +58,21 @@ class SofaPysimBlocksController(Sofa.Core.Controller): IS_READY: Set to True by :meth:`prepare_scene` when the scene is ready to start the control loop. SOFA_MASTER: If True, SOFA is the time master. - root: SOFA root node. inputs: Dict of input signals written by :meth:`set_inputs`. outputs: Dict of output signals populated by :meth:`get_outputs`. - variables_to_log: List of signal names to log at each step. - verbose: If True, print logged variables at each control step. - dt: SOFA simulation time step in seconds. Must be set by the subclass. + dt: SOFA simulation time step in seconds. Set automatically at runtime. + project_yaml: Path to the pySimBlocks YAML project file. sim: The pySimBlocks :class:`Simulator` instance, or None. + variables_to_log: List of signal names to log at each step. + node: The SOFA node to which the controller is attached, set automatically at runtime. step_index: Total number of SOFA animation steps executed. - project_yaml: Path to the pySimBlocks YAML project file. + verbose: If True, print logged variables at each control step. """ - def __init__(self, root: Sofa.Core.Node, name: str = "SofaControllerGui"): + def __init__(self, name: str = "SofaControllerGui"): """Initialize the SOFA–pySimBlocks controller. Args: - root: SOFA root node. name: Name passed to the SOFA controller base class. """ super().__init__(name=name) @@ -82,12 +81,12 @@ def __init__(self, root: Sofa.Core.Node, name: str = "SofaControllerGui"): self.SOFA_MASTER = True self._imgui = _imgui - self.root = root self.inputs: Dict[str, np.ndarray] = {} self.outputs: Dict[str, np.ndarray] = {} self.variables_to_log: List[str] = [] self.verbose = False + self.node: Sofa.Core.Node | None = None self.dt: float | None = None self.sim: Simulator | None = None self.step_index: int = 0 @@ -174,6 +173,7 @@ def onAnimateBeginEvent(self, event) -> None: return if self.sim is None: + self._init_sofa_context() self._prepare_pysimblocks() self._get_sofa_outputs() self._set_sofa_plot() @@ -207,6 +207,20 @@ def onAnimateBeginEvent(self, event) -> None: # Private methods # -------------------------------------------------------------------------- + def _init_sofa_context(self): + """Resolve the SOFA node and dt from the controller context.""" + node = self.getContext() + if not isinstance(node, Sofa.Core.Node): + self._init_failed = True + raise RuntimeError("[pySimBlocks] ERROR: Controller context is not a SOFA node.") + self.node = node + try: + self.dt = float(self.node.getRootContext().dt.value) + except AttributeError: + self._init_failed = True + raise RuntimeError("[pySimBlocks] ERROR: Could not read SOFA time step (dt).") + + def _build_model(self) -> None: """Load the pySimBlocks model from ``project_yaml``.""" project_path = self.project_yaml @@ -224,10 +238,6 @@ def _prepare_pysimblocks(self) -> None: if self.SOFA_MASTER and self.project_yaml is None: self._init_failed = True raise RuntimeError("[pySimBlocks] ERROR: SOFA_MASTER=True requires project_yaml.") - if self.dt is None: - self._init_failed = True - raise ValueError("[pySimBlocks] ERROR: SOFA_MASTER=True requires self.dt to be set to the SOFA time step.") - self._build_model() self._detect_sofa_exchange_block() self._secure_keys() @@ -322,7 +332,7 @@ def _set_sofa_plot(self) -> None: if self.sim is None: raise RuntimeError("[pySimBlocks] ERROR: Simulator not initialized.") - self._plot_node = self.root.addChild("PLOT") + self._plot_node = self.node.addChild("PLOT") self._plot_data = {} for plot in self.plot_cfg.plots: for var in plot["signals"]: @@ -357,7 +367,7 @@ def _set_sofa_slider(self) -> None: data = self._sofa_block.slider_params data = data if data is not None else {} - self._slider_node = self.root.addChild("SLIDERS") + self._slider_node = self.node.addChild("SLIDERS") self._slider_data = {} for var, extremum in data.items(): block_name, key = var.split(".") From 587c4bef169064b20533df368667b4b40c1a05ae Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 24 Mar 2026 23:48:56 +0100 Subject: [PATCH 12/19] chore: remove dead metadata fields from project YAML --- examples/advanced/hardware/project.yaml | 3 --- examples/advanced/qp_problem/project.yaml | 3 --- examples/advanced/sofa/gui/sofa_exchange/project.yaml | 3 --- examples/advanced/sofa/gui/sofa_plant/project.yaml | 3 --- examples/basics/algebraic_function/project.yaml | 3 --- examples/basics/dc_motor/gui/project.yaml | 3 --- examples/basics/external/project.yaml | 3 --- examples/basics/file_sources/project.yaml | 3 --- examples/basics/state_feedback/gui/project.yaml | 3 --- examples/basics/state_space/linear/gui/project.yaml | 3 --- examples/basics/state_space/non_linear/project.yaml | 3 --- examples/basics/state_space/polytope/project.yaml | 3 --- examples/quick_start/gui/project.yaml | 3 --- examples/tutorials/tutorial_2_gui/project.yaml | 3 --- examples/tutorials/tutorial_3_sofa/project.yaml | 3 --- examples/tutorials/tutorial_3_sofa/project_solution.yaml | 3 --- pySimBlocks/gui/services/yaml_tools.py | 4 ---- 17 files changed, 52 deletions(-) diff --git a/examples/advanced/hardware/project.yaml b/examples/advanced/hardware/project.yaml index e2e9a41..4368e2e 100644 --- a/examples/advanced/hardware/project.yaml +++ b/examples/advanced/hardware/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: hardware - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 1/60 T: 10.0 diff --git a/examples/advanced/qp_problem/project.yaml b/examples/advanced/qp_problem/project.yaml index a98c28d..e752076 100644 --- a/examples/advanced/qp_problem/project.yaml +++ b/examples/advanced/qp_problem/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: qp_problem - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.1 T: 10.0 diff --git a/examples/advanced/sofa/gui/sofa_exchange/project.yaml b/examples/advanced/sofa/gui/sofa_exchange/project.yaml index 0c8c077..411ba1c 100644 --- a/examples/advanced/sofa/gui/sofa_exchange/project.yaml +++ b/examples/advanced/sofa/gui/sofa_exchange/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: sofa_exchange - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.01 diff --git a/examples/advanced/sofa/gui/sofa_plant/project.yaml b/examples/advanced/sofa/gui/sofa_plant/project.yaml index e713a0f..8c7a4f0 100644 --- a/examples/advanced/sofa/gui/sofa_plant/project.yaml +++ b/examples/advanced/sofa/gui/sofa_plant/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: sofa_plant - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.01 T: 5.0 diff --git a/examples/basics/algebraic_function/project.yaml b/examples/basics/algebraic_function/project.yaml index 9869612..ec81c68 100644 --- a/examples/basics/algebraic_function/project.yaml +++ b/examples/basics/algebraic_function/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: algebraic_function - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.1 T: 10.0 diff --git a/examples/basics/dc_motor/gui/project.yaml b/examples/basics/dc_motor/gui/project.yaml index 7e573aa..899084c 100644 --- a/examples/basics/dc_motor/gui/project.yaml +++ b/examples/basics/dc_motor/gui/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: dc_motor_gui - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.01 diff --git a/examples/basics/external/project.yaml b/examples/basics/external/project.yaml index 172c655..5add6f2 100644 --- a/examples/basics/external/project.yaml +++ b/examples/basics/external/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: external - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.01 diff --git a/examples/basics/file_sources/project.yaml b/examples/basics/file_sources/project.yaml index c3a59ee..09ab2d2 100644 --- a/examples/basics/file_sources/project.yaml +++ b/examples/basics/file_sources/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: file_sources - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.05 diff --git a/examples/basics/state_feedback/gui/project.yaml b/examples/basics/state_feedback/gui/project.yaml index 36a9359..0cc7e12 100644 --- a/examples/basics/state_feedback/gui/project.yaml +++ b/examples/basics/state_feedback/gui/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: state_feedback_gui - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.02 diff --git a/examples/basics/state_space/linear/gui/project.yaml b/examples/basics/state_space/linear/gui/project.yaml index d261425..fb4beaf 100644 --- a/examples/basics/state_space/linear/gui/project.yaml +++ b/examples/basics/state_space/linear/gui/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: linear_state_space_gui - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.01 diff --git a/examples/basics/state_space/non_linear/project.yaml b/examples/basics/state_space/non_linear/project.yaml index 696cf80..c720c7b 100644 --- a/examples/basics/state_space/non_linear/project.yaml +++ b/examples/basics/state_space/non_linear/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: non_linear_state_space - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.1 diff --git a/examples/basics/state_space/polytope/project.yaml b/examples/basics/state_space/polytope/project.yaml index 0f3c120..ea4589d 100644 --- a/examples/basics/state_space/polytope/project.yaml +++ b/examples/basics/state_space/polytope/project.yaml @@ -2,9 +2,6 @@ schema_version: 1 project: name: polytopic_state_space - metadata: - created_by: pySimBlocks - created_at: "2026-02-18T00:00:00Z" simulation: dt: 0.1 diff --git a/examples/quick_start/gui/project.yaml b/examples/quick_start/gui/project.yaml index 7fe1a20..8208110 100644 --- a/examples/quick_start/gui/project.yaml +++ b/examples/quick_start/gui/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: gui - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.05 T: 30.0 diff --git a/examples/tutorials/tutorial_2_gui/project.yaml b/examples/tutorials/tutorial_2_gui/project.yaml index 188c5e1..d156a81 100644 --- a/examples/tutorials/tutorial_2_gui/project.yaml +++ b/examples/tutorials/tutorial_2_gui/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: tutorial_2_gui - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.01 T: 5.0 diff --git a/examples/tutorials/tutorial_3_sofa/project.yaml b/examples/tutorials/tutorial_3_sofa/project.yaml index 188c5e1..d156a81 100644 --- a/examples/tutorials/tutorial_3_sofa/project.yaml +++ b/examples/tutorials/tutorial_3_sofa/project.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: tutorial_2_gui - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.01 T: 5.0 diff --git a/examples/tutorials/tutorial_3_sofa/project_solution.yaml b/examples/tutorials/tutorial_3_sofa/project_solution.yaml index 9ca39a6..d671706 100644 --- a/examples/tutorials/tutorial_3_sofa/project_solution.yaml +++ b/examples/tutorials/tutorial_3_sofa/project_solution.yaml @@ -1,9 +1,6 @@ schema_version: 1 project: name: tutorial_3_sofa - metadata: - created_by: pySimBlocks - created_at: '2026-02-18T00:00:00Z' simulation: dt: 0.01 T: 10.0 diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index 68a8eaf..fdc5d73 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -306,10 +306,6 @@ def build_project_yaml( "schema_version": 1, "project": { "name": project_name, - "metadata": { - "created_by": "pySimBlocks", - "created_at": "2026-02-18T00:00:00Z", - }, }, "simulation": _build_simulation_section(project_state), "diagram": { From c968cd2513d793706bb1dbcaea49ef6e1e18c08c Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 25 Mar 2026 09:53:12 +0100 Subject: [PATCH 13/19] feat(docs): simulation documentation --- docs/source/concepts/adding_block.md | 6 + docs/source/concepts/index.rst | 1 + docs/source/concepts/simulation_lifecycle.md | 134 +++++++++++++++++- .../simulator_lifecycle_flowchart.svg | 49 +++++++ .../images/concepts/simulator_step_phases.svg | 42 ++++++ 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 docs/source/concepts/adding_block.md create mode 100644 docs/source/images/concepts/simulator_lifecycle_flowchart.svg create mode 100644 docs/source/images/concepts/simulator_step_phases.svg diff --git a/docs/source/concepts/adding_block.md b/docs/source/concepts/adding_block.md new file mode 100644 index 0000000..ca3c897 --- /dev/null +++ b/docs/source/concepts/adding_block.md @@ -0,0 +1,6 @@ +# Adding a Block + +This page describes how to add a block in the pySimBlocks framework. A block is +a fundamental building unit in a model, representing a specific function or +operation. To add a block, you need to define its inputs, outputs, parameters, +and internal state. diff --git a/docs/source/concepts/index.rst b/docs/source/concepts/index.rst index 0a60320..e24038b 100644 --- a/docs/source/concepts/index.rst +++ b/docs/source/concepts/index.rst @@ -11,3 +11,4 @@ does, how it thinks about blocks and signals, and why it behaves the way it does block_model execution_order glossary + adding_block diff --git a/docs/source/concepts/simulation_lifecycle.md b/docs/source/concepts/simulation_lifecycle.md index c2253e3..eb8a3bd 100644 --- a/docs/source/concepts/simulation_lifecycle.md +++ b/docs/source/concepts/simulation_lifecycle.md @@ -1,3 +1,133 @@ -# Simulation Life Cycle +# Simulator & Simulation Life Cycle -This page describes the sequence of phases a simulation goes through from initialization to teardown, and what happens at each step. +## Overview + +The `Simulator` is the central object that drives a pySimBlocks simulation. +It takes a {py:class}`~pySimBlocks.core.model.Model` and a +{py:class}`~pySimBlocks.core.config.SimulationConfig`, and executes the block +diagram step by step until the end time is reached. + +A simulation goes through three successive stages: + +![Simulation lifecycle](../images/concepts/simulator_lifecycle_flowchart.svg) + +```python +sim = Simulator(model, sim_cfg) # triggers _compile() +logs = sim.run() # triggers initialize() then step loop +``` + +## Compilation phase + +`_compile()` is called automatically when the `Simulator` is instantiated. +It prepares everything needed to run the simulation loop and raises immediately +if the configuration is invalid. + +Three things happen in sequence: + +**1. Execution order.** The block graph is sorted topologically to determine +in which order `output_update()` is called each step. See +{doc}`execution_order` for details. + +**2. Task grouping.** Blocks are grouped by effective sample time into +{py:class}`~pySimBlocks.core.task.Task` objects. Each task owns an ordered +subset of the global execution order and tracks its own activation schedule. +Blocks with no explicit `sample_time` inherit the global `dt`. + +**3. Time manager.** A +{py:class}`~pySimBlocks.core.fixed_time_manager.FixedStepTimeManager` is +created and validates that all block sample times are integer multiples of +`dt`. If any is not, an error is raised before the simulation starts. +```{note} +Only `"fixed"` step is currently supported. Variable-step raises +`NotImplementedError`. +``` + +## Initialization phase + +`initialize()` is called once before the step loop starts, either automatically +by `run()` or manually when using an external clock. + +It iterates over all blocks in execution order, calls `block.initialize(t0)`, +then immediately propagates the block's outputs to its downstream inputs. +This ensures that every block enters the first step with consistent input values. + +Once all blocks are initialized, each task refreshes its list of stateful +blocks — those whose `state_update()` must be called each step. +```{note} +If any block raises during `initialize()`, the error is re-raised immediately +with the block name, and the simulation does not start. +``` + +## Simulation step + +Each call to `step()` executes three phases in strict order, applied only +to the tasks active at the current time `t`. + +![Simulation step phases](../images/concepts/simulator_step_phases.svg) + +### Phase 1: Output update & propagation + +For each active task, blocks call `output_update(t, dt)` in topological order. +Each block computes its output `y[k]` — from `x[k]` if it has state, +from `u[k]` if it has direct-feedthrough, or from both. Then its outputs are +immediately propagated to the inputs of downstream blocks. + +The propagation happens block by block, not after all blocks have run. This +ensures that each block receives up-to-date inputs before its own +`output_update()` is called. + +The topological order guarantees that for any block with direct-feedthrough, +its upstream blocks have already computed their outputs at the same step. See +{doc}`execution_order` for how this order is built and what happens when a +cycle is detected. + +### Phase 2 — State update + +For blocks that have internal state, `state_update(t, dt)` computes `x[k+1]` +from `x[k]` and `u[k]` and stores it in `next_state`. Stateless blocks +(e.g. `Gain`, `Sum`) skip this phase entirely. + +### Phase 3 — Commit + +For blocks that have internal state, `next_state` is copied into `state`: +`x[k] ← x[k+1]`. The simulation clock then advances: `t ← t + dt`. + +After commit, the step is complete and the scheduler determines which tasks +are active at the next tick. + +## Multi-rate scheduling + +A pySimBlocks model can mix blocks running at different sample times. A +controller might run at 10 ms while a slower observer runs at 50 ms, both +coexisting in the same diagram. + +### Tasks and sample times + +During compilation, blocks are grouped by effective sample time into +{py:class}`~pySimBlocks.core.task.Task` objects. Each task owns the subset +of blocks sharing its sample time and tracks its own activation schedule +independently. + +Blocks with no explicit `sample_time` inherit the global `dt` from +`SimulationConfig`. All sample times must be integer multiples of `dt` — +this is validated at compile time. + +### Scheduler activation + +At each tick, the {py:class}`~pySimBlocks.core.scheduler.Scheduler` checks +which tasks are due to run. A task runs if `t >= next_activation`. After +execution, its `next_activation` advances by one period. + +For a model with `dt=10ms`, a fast task at 10 ms and a slow task at 50 ms: + +| t (ms) | fast task | slow task | +|--------|-----------|-----------| +| 0 | runs | runs | +| 10 | runs | — | +| 20 | runs | — | +| 30 | runs | — | +| 40 | runs | — | +| 50 | runs | runs | + +Blocks in inactive tasks hold their last output — they are neither updated +nor propagated until their next activation. diff --git a/docs/source/images/concepts/simulator_lifecycle_flowchart.svg b/docs/source/images/concepts/simulator_lifecycle_flowchart.svg new file mode 100644 index 0000000..df72830 --- /dev/null +++ b/docs/source/images/concepts/simulator_lifecycle_flowchart.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + Compile + Execution order, + task grouping + + + + + + + + + Initialize + initialize() on each + block, propagate + + + + + + + + + Step loop + t < T + output update + state update + commit + + + + + next step + + + Simulator(model, cfg) + sim.run() or + sim.initialize() + sim.step() + \ No newline at end of file diff --git a/docs/source/images/concepts/simulator_step_phases.svg b/docs/source/images/concepts/simulator_step_phases.svg new file mode 100644 index 0000000..b23fde1 --- /dev/null +++ b/docs/source/images/concepts/simulator_step_phases.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + Phase 1 — output update + y[k] = f(x[k], u[k]) + propagate + + + + + + + + + Phase 2 — state update + x[k+1] = g(x[k], u[k]) + + + + + + + + + Phase 3 — commit + x[k] ← x[k+1], t ← t + dt + + + + + next + step + + + repeated while t < T + \ No newline at end of file From 80c1915f825045d0f65ed028d769b35943eb1005 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 25 Mar 2026 10:39:39 +0100 Subject: [PATCH 14/19] feat(docs): execution order section --- docs/source/concepts/execution_order.md | 110 +++++++++++++++++- docs/source/concepts/index.rst | 4 +- .../concepts/direct_feedthrough_graph.svg | 57 +++++++++ 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 docs/source/images/concepts/direct_feedthrough_graph.svg diff --git a/docs/source/concepts/execution_order.md b/docs/source/concepts/execution_order.md index eb75d56..22fc06b 100644 --- a/docs/source/concepts/execution_order.md +++ b/docs/source/concepts/execution_order.md @@ -1,3 +1,111 @@ # Execution Order and Algebraic Loops -This page explains how pySimBlocks determines the order in which blocks are executed each time step, and what happens when a dependency cycle (algebraic loop) is detected. +## Overview + +Before the simulation loop starts, pySimBlocks determines the order in which +blocks must call `output_update()` at each step. This order is computed once +during the {doc}`compilation phase ` and reused at every tick. + +The ordering problem reduces to a single question: for a given block, have all +its upstream blocks with direct feedthrough already computed their output at +this step? If not, the block would read a stale input — which is incorrect. + +A block has direct feedthrough if its output at step `k` depends on its input at +the same step `k` — i.e. `u[k]` appears in `output_update()`. + +If a dependency cycle exists between blocks with direct feedthrough and no +stateful block breaks it, the order cannot be resolved. This is an +**algebraic loop** and pySimBlocks raises a `RuntimeError` before the +simulation starts. + +## Building the execution order + +### Direct feedthrough graph + +Not all signal edges constrain execution order. Only edges where the +destination block has direct feedthrough create a dependency: the source +must compute its output before the destination can compute its own. + +pySimBlocks builds a directed graph containing only these edges, then sorts +it topologically. Blocks without direct feedthrough — like `Delay` — are +ignored in this graph and can execute in any order. + +![Direct feedthrough graph](../images/concepts/direct_feedthrough_graph.svg) + +In this example, `Delay` and `Step` have no ordering constraint between them. +`Delay` is placed first arbitrarily. `Gain` must follow `Step` (it reads +`Step`'s output directly), and `Sum` must follow both `Gain` and `Delay`. + +### Topological sort + +pySimBlocks uses Kahn's algorithm. It starts from all blocks with no +incoming direct-feedthrough edges (indegree = 0) and processes them one +by one, decrementing the indegree of their successors. A block is added +to the execution order as soon as its indegree reaches zero. + +For the example above: + +| Step | Ready queue | Executed | +|------|-------------|----------| +| init | Delay, Step | — | +| 1 | Step | Delay ① | +| 2 | Gain | Step ② | +| 3 | Sum | Gain ③ | +| 4 | — | Sum ④ | + +The order between blocks with indegree 0 at the same time (here `Delay` +and `Step`) is non-deterministic and may vary between runs. + +## Algebraic loop + +### What is an algebraic loop? + +An algebraic loop occurs when two or more blocks with direct feedthrough form +a cycle in the dependency graph. Block A needs B's output to compute its own, +and B needs A's output — neither can go first. + +A simple example: a `Sum` block whose output feeds back into itself through +a `Gain`, with both having direct feedthrough and no stateful block breaking +the cycle. +```{code-block} python +model.connect("sum", "out", "gain", "in") +model.connect("gain", "out", "sum", "in1") +``` + +This is mathematically an implicit equation `y = f(y)` that pySimBlocks +cannot resolve in discrete time without a delay or a stateful block. + +### How algebraic loops are detected and reported + +Kahn's algorithm detects the loop naturally: if after processing all blocks +with indegree 0 some blocks remain unprocessed, it means they are part of a +cycle. pySimBlocks raises immediately: +``` +RuntimeError: Algebraic loop detected: direct-feedthrough cycle exists. +``` + +This error is raised during the compilation phase, before `initialize()` is +called. The fix is always one of: + +- Insert a `Delay` block to break the cycle. +- Replace a direct-feedthrough block with a stateful equivalent (e.g. + `DiscreteIntegrator` in Euler forward mode). + +### Stateful blocks as cycles breakers + +A block breaks a cycle only if it has no direct feedthrough. In that case, +`output_update()` only reads `x[k]` — the state from the previous step — +so its incoming signal edges are not direct-feedthrough edges and do not +appear in the dependency graph. + +Even if block A feeds block B which feeds back into A, if A has no direct +feedthrough the dependency graph has no edge B → A, and the cycle +disappears. + +This is exactly how Simulink handles algebraic loops, and pySimBlocks follows +the same convention. +```{note} +A stateful block with `direct_feedthrough = True` (e.g. `DiscreteIntegrator` +in Euler backward mode) does **not** break cycles — its incoming edges are +still in the dependency graph. +``` diff --git a/docs/source/concepts/index.rst b/docs/source/concepts/index.rst index e24038b..069f646 100644 --- a/docs/source/concepts/index.rst +++ b/docs/source/concepts/index.rst @@ -8,7 +8,7 @@ does, how it thinks about blocks and signals, and why it behaves the way it does :maxdepth: 1 simulation_lifecycle - block_model execution_order - glossary + block_model adding_block + glossary diff --git a/docs/source/images/concepts/direct_feedthrough_graph.svg b/docs/source/images/concepts/direct_feedthrough_graph.svg new file mode 100644 index 0000000..0227222 --- /dev/null +++ b/docs/source/images/concepts/direct_feedthrough_graph.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + Step + no feedthrough + + + + + + + Gain + direct feedthrough + + + + + + + Delay + no feedthrough + + + + + + + Sum + direct feedthrough + + + + + + + + + + + + + + + + + + direct feedthrough edge (constrains order) + + signal edge (no ordering constraint) + \ No newline at end of file From 143e8e7fd1f145379dfd2ee05d346215d9d62690 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 25 Mar 2026 10:48:33 +0100 Subject: [PATCH 15/19] fix(docs): windows installation path fail --- docs/source/user_guide/installation.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/source/user_guide/installation.md b/docs/source/user_guide/installation.md index e66e70c..67fc461 100644 --- a/docs/source/user_guide/installation.md +++ b/docs/source/user_guide/installation.md @@ -48,3 +48,19 @@ To run the tests, you need to install the testing dependencies: ```bash pip install pySimBlocks[tests] ``` + +## Troubleshooting + +### Windows: installation fails with "No such file or directory" + +This error is caused by Windows' 260-character path limit (MAX_PATH). It typically +affects Python installed from the Microsoft Store, which uses a long AppData path. + +**Fix:** enable long paths in PowerShell (administrator): +```powershell +New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force +``` + +Then restart and reinstall. If the problem persists, reinstall Python from +[python.org](https://www.python.org/downloads/windows/) instead of the Microsoft Store. From 3dcf9c8d7a0659c4ee193bd5c5d8d11507f6e3aa Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 25 Mar 2026 16:45:04 +0100 Subject: [PATCH 16/19] fix(core): hardware runner fix --- pySimBlocks/core/scheduler.py | 20 +++++---- pySimBlocks/core/simulator.py | 32 +++++++------- pySimBlocks/core/task.py | 78 +++++++++++++++++------------------ 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/pySimBlocks/core/scheduler.py b/pySimBlocks/core/scheduler.py index cb3ff14..5d2136f 100644 --- a/pySimBlocks/core/scheduler.py +++ b/pySimBlocks/core/scheduler.py @@ -40,13 +40,19 @@ def __init__(self, tasks: list[Task]): # Public methods # -------------------------------------------------------------------------- - def active_tasks(self, t: float) -> list[Task]: - """Return all tasks due to run at time t. - - Args: - t: Current simulation time in seconds. + def active_tasks(self) -> list[Task]: + """Return all tasks due to run at the current tick. Returns: - List of tasks whose should_run(t) returns True. + List of tasks whose should_run() returns True. + """ + return [task for task in self.tasks if task.should_run()] + + def tick(self) -> None: + """Advance all task countdowns by one tick. + + Must be called once per simulator tick, regardless of which tasks + were active. """ - return [task for task in self.tasks if task.should_run(t)] + for task in self.tasks: + task.advance() diff --git a/pySimBlocks/core/simulator.py b/pySimBlocks/core/simulator.py index fe45091..27b923c 100644 --- a/pySimBlocks/core/simulator.py +++ b/pySimBlocks/core/simulator.py @@ -125,29 +125,32 @@ def step(self, dt_override: float | None = None) -> None: raise RuntimeError( "[Simulator] dt_override must be provided when using external clock." ) - else: - dt_scheduler = float(dt_override) - if dt_scheduler <= 0.0: - raise ValueError(f"[Simulator] dt_override must be > 0. Got {dt_scheduler}") - else: # internal clock + dt_scheduler = float(dt_override) + if dt_scheduler <= 0.0: + raise ValueError(f"[Simulator] dt_override must be > 0. Got {dt_scheduler}") + else: if dt_override is not None: raise RuntimeError( "[Simulator] dt_override should not be provided when using internal clock." ) dt_scheduler = self.time_manager.next_dt(self.t) - active_tasks = self.scheduler.active_tasks(self.t) + # 1) Accumulate dt for all tasks + for task in self.tasks: + task.accumulate(dt_scheduler) + + active_tasks = self.scheduler.active_tasks() # PHASE 1 — outputs for task in active_tasks: - dt_task = task.get_dt(self.t) + dt_task = task.accumulated_dt for block in task.output_blocks: block.output_update(self.t, dt_task) self._propagate_from(block) # PHASE 2 — states for task in active_tasks: - dt_task = task.get_dt(self.t) + dt_task = task.accumulated_dt for block in task.state_blocks: block.state_update(self.t, dt_task) @@ -156,8 +159,10 @@ def step(self, dt_override: float | None = None) -> None: for block in task.state_blocks: block.commit_state() + # 4) Advance all countdowns and reset accumulated dt for active tasks + self.scheduler.tick() for task in active_tasks: - task.advance() + task.reset_accumulated_dt() self.t_step = self.t self.t += dt_scheduler @@ -285,14 +290,13 @@ def _compile(self) -> None: tasks_by_ts.setdefault(sample_time, []).append(b) self.tasks = [ - Task(sample_time, blocks, self.output_order) + Task(sample_time, + period_ticks=round(sample_time / self.sim_cfg.dt), + blocks=blocks, + global_output_order=self.output_order) for sample_time, blocks in tasks_by_ts.items() ] - self._single_task = (len(self.tasks) == 1) - if self._single_task: - self._task0 = self.tasks[0] - self.scheduler = Scheduler(self.tasks) if self.sim_cfg.solver == "fixed": diff --git a/pySimBlocks/core/task.py b/pySimBlocks/core/task.py index 07f3748..76b1c2c 100644 --- a/pySimBlocks/core/task.py +++ b/pySimBlocks/core/task.py @@ -29,20 +29,27 @@ class Task: Manages the scheduling and execution of output updates, state updates, and state commits for all blocks in the group. + Scheduling is tick-based: the task maintains an integer countdown reset + to ``period_ticks - 1`` after each activation. This avoids floating-point + time comparisons and works correctly with both fixed and external clocks. + Attributes: sample_time: Sampling period of this task in seconds. - next_activation: Simulation time of the next scheduled execution. - last_activation: Simulation time of the last execution, or None if - the task has never run. + period_ticks: Number of base ticks between two activations. + ticks_until_next: Countdown to the next activation. + accumulated_dt: Accumulated time since the last activation. output_blocks: Blocks ordered for output computation, filtered from the global output order. state_blocks: Subset of output_blocks that carry internal state. """ - def __init__(self, - sample_time: float, - blocks: List[Block], - global_output_order: List[Block]): + def __init__( + self, + sample_time: float, + period_ticks: int, + blocks: List[Block], + global_output_order: List[Block], + ): """Initialize a task. Args: @@ -52,54 +59,45 @@ def __init__(self, used to filter and preserve execution order within the task. """ self.sample_time = sample_time - self.next_activation = 0.0 - self.last_activation = None + self.period_ticks = period_ticks + self.ticks_until_next = 0 + self.accumulated_dt: float = 0.0 - self.output_blocks = [ - b for b in global_output_order - if b in blocks - ] + self.output_blocks = [b for b in global_output_order if b in blocks] self.state_blocks = [] + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- def update_state_blocks(self) -> None: """Refresh the list of stateful blocks from output_blocks.""" - self.state_blocks = [ - b for b in self.output_blocks - if b.has_state - ] + self.state_blocks = [b for b in self.output_blocks if b.has_state] - def should_run(self, t: float, eps: float = 1e-12) -> bool: + def should_run(self) -> bool: """Return True if the task is due to run at time t. - Args: - t: Current simulation time in seconds. - eps: Tolerance for floating-point time comparison. - Returns: - True if t >= next_activation (within eps). + True if ticks_until_next is zero, indicating that the task should run at the current time step. """ - return t + eps >= self.next_activation + return self.ticks_until_next == 0 + + def advance(self) -> None: + """Advance the countdown by one tick.""" + if self.ticks_until_next == 0: + self.ticks_until_next = self.period_ticks - 1 + else: + self.ticks_until_next -= 1 + + def accumulate(self, dt: float) -> None: + """Accumulate the time step dt since the last activation. - def get_dt(self, t: float) -> float: - """Return the elapsed time since the last activation. - - Returns sample_time on the first call (before any activation). - Args: - t: Current simulation time in seconds. - - Returns: - Elapsed time since last_activation, or sample_time if never run. + dt: Time step in seconds to accumulate. """ - if self.last_activation is None: - return self.sample_time - return t - self.last_activation + self.accumulated_dt += dt - def advance(self) -> None: - """Advance activation timestamps by one sample period.""" - self.last_activation = self.next_activation - self.next_activation += self.sample_time + def reset_accumulated_dt(self) -> None: + """Reset the accumulated time to zero after an activation.""" + self.accumulated_dt = 0.0 From a03a805584617d377406c494a1c28a68d420cc33 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Wed, 25 Mar 2026 23:23:07 +0100 Subject: [PATCH 17/19] fix(gui): remove unused external ports --- pySimBlocks/gui/blocks/interfaces/external_input.py | 9 --------- pySimBlocks/gui/blocks/interfaces/external_output.py | 8 -------- 2 files changed, 17 deletions(-) diff --git a/pySimBlocks/gui/blocks/interfaces/external_input.py b/pySimBlocks/gui/blocks/interfaces/external_input.py index afefa7d..364164c 100644 --- a/pySimBlocks/gui/blocks/interfaces/external_input.py +++ b/pySimBlocks/gui/blocks/interfaces/external_input.py @@ -54,15 +54,6 @@ def __init__(self): ) ] - self.inputs = [ - PortMeta( - name="in", - display_as="in", - shape=["n", 1], - description="External input signal." - ) - ] - self.outputs = [ PortMeta( name="out", diff --git a/pySimBlocks/gui/blocks/interfaces/external_output.py b/pySimBlocks/gui/blocks/interfaces/external_output.py index d393d8b..43580db 100644 --- a/pySimBlocks/gui/blocks/interfaces/external_output.py +++ b/pySimBlocks/gui/blocks/interfaces/external_output.py @@ -62,11 +62,3 @@ def __init__(self): ) ] - self.outputs = [ - PortMeta( - name="out", - display_as="out", - shape=["m", 1], - description="External output signal." - ) - ] From 2e73de52f9d80a317495b68a078158f7444b4c0d Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Thu, 26 Mar 2026 00:13:32 +0100 Subject: [PATCH 18/19] feat(docs): adds glossary and block --- docs/source/concepts/adding_block.md | 150 +++++++++++++++++++++++- docs/source/concepts/block_model.md | 111 +++++++++++++++++- docs/source/concepts/execution_order.md | 3 +- docs/source/concepts/glossary.md | 66 ++++++++++- 4 files changed, 322 insertions(+), 8 deletions(-) diff --git a/docs/source/concepts/adding_block.md b/docs/source/concepts/adding_block.md index ca3c897..6172a4f 100644 --- a/docs/source/concepts/adding_block.md +++ b/docs/source/concepts/adding_block.md @@ -1,6 +1,146 @@ -# Adding a Block +# Adding a New Block -This page describes how to add a block in the pySimBlocks framework. A block is -a fundamental building unit in a model, representing a specific function or -operation. To add a block, you need to define its inputs, outputs, parameters, -and internal state. +## Overview + +Adding a block to pySimBlocks requires creating or modifying three things: + +- `pySimBlocks/blocks//my_block.py` — the core simulation logic +- `pySimBlocks/gui/blocks//my_block.py` — the GUI metadata +- `pySimBlocks/project/pySimBlocks_blocks_index.yaml` — the block registry entry + +The core and GUI layers are fully independent. The core block runs without +the GUI and the GUI block contains no simulation logic. + +For a detailed description of the block interface, see {doc}`block_model`. + +## Core block + +Place the file in the sub-package matching the block's category +(`sources`, `operators`, `controllers`, etc.). The filename must be the +class name in snake_case. + +A core block must: + +- inherit from {py:class}`~pySimBlocks.core.block.Block` +- declare all input and output ports in `__init__` +- set `direct_feedthrough` at class level +- implement `initialize()` and `output_update()` +- implement `state_update()` if the block has internal state +- use `sample_time=None` to inherit the global `dt` + +The following example implements a `ScalarGain` block that multiplies its +input by a constant: +```python +import numpy as np +from pySimBlocks.core.block import Block + +class ScalarGain(Block): + + direct_feedthrough = True + + def __init__(self, name: str, gain: float, sample_time: float | None = None): + super().__init__(name, sample_time) + self.gain = float(gain) + self.inputs["in"] = None + self.outputs["out"] = None + + def initialize(self, t0: float) -> None: + self.outputs["out"] = np.zeros((1, 1)) + + def output_update(self, t: float, dt: float) -> None: + self.outputs["out"] = self.gain * self.inputs["in"] + + def state_update(self, t: float, dt: float) -> None: + pass +``` + +Register it in `pySimBlocks/blocks/operators/__init__.py`: +```python +from .scalar_gain import ScalarGain +``` + +## GUI block + +Place the file in `pySimBlocks/gui/blocks//my_block.py`. +The class name must be the `myBlockMeta`. It must inherit from +{py:class}`~pySimBlocks.gui.blocks.block_meta.BlockMeta` and declare the +following class attributes in `__init__`: + +- `name` — user-facing block name +- `category` — must match the core block category +- `type` — stable identifier used in `project.yaml` (snake_case) +- `summary` — one-line description shown in the block list +- `description` — rich text shown in the block dialog (Markdown, supports LaTeX) +- `inputs` — list of {py:class}`~pySimBlocks.gui.blocks.port_meta.PortMeta` +- `outputs` — list of {py:class}`~pySimBlocks.gui.blocks.port_meta.PortMeta` +- `parameters` — list of {py:class}`~pySimBlocks.gui.blocks.parameter_meta.ParameterMeta` + +### Minimal + +The following example is the GUI counterpart of the `ScalarGain` block: +```python +from pySimBlocks.gui.blocks.block_meta import BlockMeta +from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta +from pySimBlocks.gui.blocks.port_meta import PortMeta + +class ScalarGainMeta(BlockMeta): + + def __init__(self): + self.name = "ScalarGain" + self.category = "operators" + self.type = "scalar_gain" + self.summary = "Multiplies input by a scalar constant." + self.description = ( + "Computes:\n" + "$$\n" + "y = K \\cdot u\n" + "$$\n" + ) + self.inputs = [ + PortMeta(name="in", display_as="in", shape=["n", "m"]) + ] + self.outputs = [ + PortMeta(name="out", display_as="out", shape=["n", "m"]) + ] + self.parameters = [ + ParameterMeta(name="gain", type="float", required=True, default=1.0), + ParameterMeta(name="sample_time", type="float"), + ] +``` + +### Conditional parameters + +Override `is_parameter_active()` to show or hide parameters depending on +the current block configuration. It receives the parameter name and the +current instance parameters, and returns `True` if the parameter should +be visible. + +The following example hides `Ki` unless the selected controller includes +an integral term: +```python +def is_parameter_active(self, param_name: str, instance_params: dict) -> bool: + if param_name == "Ki": + return instance_params.get("controller") in ["I", "PI", "PID"] + return super().is_parameter_active(param_name, instance_params) +``` + +### Dynamic ports and custom dialogs +```{tip} +For dynamic ports (ports whose number depends on a parameter), override +`resolve_port_group()`. For a complete example see +{py:class}`~pySimBlocks.gui.blocks.operators.algebraic_function.AlgebraicFunctionMeta`. + +For fully custom dialog layouts (file pickers, extra buttons), override +`build_param()` and/or `build_post_param()`. See the same class for reference. +``` + +## Registering in the index + +Once the core block is registered in its `__init__.py`, run: +```bash +pysimblocks update +``` + +This regenerates `pySimBlocks/project/pySimBlocks_blocks_index.yaml` +automatically. The new block is then available in the GUI block list and +the project loader. diff --git a/docs/source/concepts/block_model.md b/docs/source/concepts/block_model.md index ae3d69e..785e1d1 100644 --- a/docs/source/concepts/block_model.md +++ b/docs/source/concepts/block_model.md @@ -1,3 +1,112 @@ # Block Model -This page describes how a block is defined in pySimBlocks — its inputs, outputs, parameters, and internal state — and how blocks are connected to form a model. +## Overview + +A block is the fundamental unit of a pySimBlocks model. It encapsulates a +discrete-time computation — sources, operators, controllers, physical plants +— and exposes a uniform interface that the {doc}`simulator ` +calls at each step. + +Every block inherits from {py:class}`~pySimBlocks.core.block.Block` and +implements at minimum `initialize()` and `output_update()`. + +To add a new block to pySimBlocks — including its GUI metadata and index +registration — see {doc}`adding_block`. + +## Anatomy of a block + +### Inputs and outputs + +Inputs and outputs are plain Python dicts mapping port names to NumPy arrays +of shape `(n, m)`. They are declared in `__init__` and updated each step. +```python +self.inputs["in"] = None # declared, not yet connected +self.outputs["out"] = None # declared, not yet computed +``` + +An input is `None` until a connection is established. An output is `None` +until `initialize()` or `output_update()` sets it. Accessing a `None` input +in `output_update()` should raise a `RuntimeError`. + +### State + +State is also a dict, split into two separate dicts: `state` holds the +current value `x[k]`, and `next_state` holds the value computed by +`state_update()` before it is committed. +```python +self.state["x"] = np.zeros((2, 1)) +self.next_state["x"] = np.zeros((2, 1)) +``` + +A block with no state simply leaves both dicts empty. The simulator checks +`block.has_state` to decide whether to call `state_update()` and +`commit_state()`. + +### Parameters + +Parameters are regular Python attributes set in `__init__`. There is no +dedicated container — a gain value, a matrix, a file path are all just +attributes. +```python +self.K = np.array(gain) +self.sample_time = sample_time +``` + +They are fixed at construction time and should not change during simulation. + +## Block lifecycle methods + +### initialize() + +Called once before the simulation loop starts, in topological order. +Must set a valid initial value for all outputs and state entries. +Receives `t0`, the initial simulation time. +```python +def initialize(self, t0: float) -> None: + self.state["x"] = np.zeros((2, 1)) + self.outputs["out"] = self.state["x"].copy() +``` + +### output_update() + +Called every step for all active blocks, in topological order. +Must compute `outputs` from `state` and `inputs`. Must not modify `state`. +```python +def output_update(self, t: float, dt: float) -> None: + self.outputs["out"] = self.state["x"].copy() +``` + +### state_update() + +Called every step, after all `output_update()` calls. Must write the next +state into `next_state`. Must not modify `state` or `outputs`. +```python +def state_update(self, t: float, dt: float) -> None: + self.next_state["x"] = self.state["x"] + dt * self.inputs["in"] +``` + +Only called if `block.has_state` is `True`. Blocks with no state can omit +this method. + +### finalize() + +Called once after the simulation loop ends. Optional — the base class +provides a no-op default. Use it to close files, release resources, or +flush buffers. + +## direct_feedthrough flag + +`direct_feedthrough` is a class-level boolean attribute that tells the +simulator whether `u[k]` appears in `output_update()`. It defaults to +`True` in the base class and should be overridden when the block's output +does not depend on its inputs at the same step. +```python +class MyIntegrator(Block): + direct_feedthrough = False +``` + +Setting it correctly is critical — it determines which edges appear in the +dependency graph and therefore the execution order. An incorrect value either +causes unnecessary ordering constraints or, worse, silently produces stale +inputs. See {doc}`execution_order` for details. + diff --git a/docs/source/concepts/execution_order.md b/docs/source/concepts/execution_order.md index 22fc06b..6203518 100644 --- a/docs/source/concepts/execution_order.md +++ b/docs/source/concepts/execution_order.md @@ -11,7 +11,8 @@ its upstream blocks with direct feedthrough already computed their output at this step? If not, the block would read a stale input — which is incorrect. A block has direct feedthrough if its output at step `k` depends on its input at -the same step `k` — i.e. `u[k]` appears in `output_update()`. +the same step `k` — i.e. `u[k]` appears in `output_update()`. See +{doc}`block_model` for how `direct_feedthrough` is declared in practice. If a dependency cycle exists between blocks with direct feedthrough and no stateful block breaks it, the order cannot be resolved. This is an diff --git a/docs/source/concepts/glossary.md b/docs/source/concepts/glossary.md index 5727473..42e9a54 100644 --- a/docs/source/concepts/glossary.md +++ b/docs/source/concepts/glossary.md @@ -1,3 +1,67 @@ # Glossary +```{glossary} +Algebraic loop + A cycle in the direct-feedthrough dependency graph. If block A needs + B's output to compute its own, and B needs A's output, neither can go + first. pySimBlocks raises a `RuntimeError` at compile time when such + a cycle is detected. + See {doc}`execution_order`. -This page defines the key terms used throughout the pySimBlocks documentation, such as signal, sample time, direct feedthrough, and algebraic loop. +Block + The fundamental unit of a pySimBlocks model. A block encapsulates a + discrete-time computation and exposes a uniform interface — `initialize()`, + `output_update()`, `state_update()` — called by the simulator at each step. + +Direct feedthrough + A block has direct feedthrough if its output at step `k` depends on + its input at the same step `k` — i.e. `u[k]` appears in + `output_update()`. This property determines which edges appear in the + dependency graph and therefore the execution order. + See {doc}`execution_order`. + +Execution order + The ordered list of blocks in which `output_update()` is called at + each simulation step. Computed once during the compilation phase by + a topological sort of the direct-feedthrough dependency graph. + See {doc}`execution_order`. + +Model + A container that holds blocks and signal connections. The model builds + the topological execution order and provides fast access to downstream + connections. It is passed to the `Simulator` at construction time. + See {py:class}`~pySimBlocks.core.model.Model`. + +Port + A named connection point on a block. Input ports receive signals from + upstream blocks; output ports emit signals to downstream blocks. + Ports are declared in `__init__` as entries in `self.inputs` and + `self.outputs`. + +Sample time + The period in seconds at which a block is activated. Blocks with + `sample_time=None` inherit the global `dt` from `SimulationConfig`. + All sample times must be integer multiples of `dt`. + +Signal + A NumPy array of shape `(n, m)` flowing between two ports through a + connection. All signals are discrete-time: a signal has a defined value + at each simulation step `k`. + +Simulator + The central object that drives a pySimBlocks simulation. It takes a + `Model` and a `SimulationConfig`, compiles the execution order, and + runs the step loop until the end time is reached. + See {doc}`simulation_lifecycle`. + +State + The internal memory of a block, split into two dicts: `state` holds + the committed value `x[k]` used during `output_update()`, and + `next_state` holds the value `x[k+1]` computed by `state_update()` + before it is committed at the end of the step. + +Task + A group of blocks sharing the same sample time. The simulator groups + blocks into tasks at compile time and activates each task only when + its period has elapsed. + See {doc}`simulation_lifecycle`. +``` From 12f50a940f9ba0f5f403608461141ec008f93660 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Thu, 26 Mar 2026 00:30:16 +0100 Subject: [PATCH 19/19] fix(tests): multitask new compatibility --- tests/core/test_core_multirate_tasks.py | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/core/test_core_multirate_tasks.py b/tests/core/test_core_multirate_tasks.py index d70c4a7..7a87303 100644 --- a/tests/core/test_core_multirate_tasks.py +++ b/tests/core/test_core_multirate_tasks.py @@ -33,21 +33,30 @@ def state_update(self, t: float, dt: float): def test_task_get_dt_semantics(capsys): """ - Contract test for Task.get_dt(): - - first activation returns Ts - - subsequent calls return (t - last_activation) + Contract test for tick-based Task scheduling: + - task starts with ticks_until_next == 0 (should run at t=0) + - accumulated_dt tracks elapsed time since last activation + - advance() decrements ticks_until_next (or resets to period_ticks-1) + - reset_accumulated_dt() clears the accumulator after activation This test is isolated from Simulator (unit test of Task). """ - task = Task(sample_time=0.1, blocks=[], global_output_order=[]) + task = Task(sample_time=0.1, period_ticks=2, blocks=[], global_output_order=[]) - assert task.get_dt(0.0) == pytest.approx(0.1) + assert task.should_run() # starts ready at t=0 - # Emulate one advance cycle (as Simulator would do) - task.advance() # last_activation becomes 0.0 - assert task.last_activation == pytest.approx(0.0) + # Emulate one activation cycle (as Simulator would do) + task.accumulate(0.1) + assert task.accumulated_dt == pytest.approx(0.1) + task.advance() # ticks_until_next -> period_ticks - 1 = 1 + task.reset_accumulated_dt() - assert task.get_dt(0.3) == pytest.approx(0.3 - 0.0) + assert not task.should_run() # ticks_until_next == 1 + task.accumulate(0.1) + task.advance() # ticks_until_next -> 0 + + assert task.should_run() # due again + assert task.accumulated_dt == pytest.approx(0.1) def test_multirate_activation_and_hold(capsys):