diff --git a/README.md b/README.md
index d5788bd..710bc03 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,18 @@
# pySimBlocks
-A deterministic block-diagram simulation framework for discrete-time modeling, co-simulation and research prototyping in Python.
+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 using either:
+pySimBlocks allows you to build, configure, and execute discrete-time systems
+using either:
- A pure Python API
- A graphical editor (PySide6)
- YAML project configuration
- Optional SOFA and hardware integration
+
+
## Features
- Block-based modeling (Simulink-like)
@@ -36,74 +40,65 @@ cd pySimBlocks
pip install .
```
-## First Steps
+## Getting Started
### Quick Example
-The following example models a damped harmonic oscillator:
-
-$$ \ddot{x} +0.5\dot{x} +2x = 0 $$
+The following example models a simple first-order low-pass filter, defined by
+the difference equation:
-The continuous-time equation is implemented using explicit forward Euler
-discretization through discrete integrator blocks with a fixed time step.
+$$ y[k] = \alpha x[k] + (1-\alpha) y[k-1] $$
-The system is assembled explicitly from discrete-time operators.
+It can be implemented in pySimBlocks using the following code:
```python
from pySimBlocks import Model, Simulator, SimulationConfig, PlotConfig
-from pySimBlocks.blocks.operators import Gain, Sum, DiscreteIntegrator
+from pySimBlocks.blocks.operators import Gain, Sum, Delay
+from pySimBlocks.blocks.sources import WhiteNoise
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="--")
+noise = WhiteNoise(name="noise", std=1.0)
+delay = Delay(name="delay")
+filtered = Sum("filtered", signs="++")
+alpha_gain = Gain(name="alpha", gain=0.1)
+complement = Gain(name="complement", gain=0.9)
# 2. Build the model
model = Model("Example")
-for block in [v, x, damping, stiffness, sum]:
+for block in [noise, delay, filtered, alpha_gain, complement]:
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")
+model.connect("noise", "out", "alpha", "in")
+model.connect("delay", "out", "complement", "in")
+model.connect("alpha", "out", "filtered", "in1")
+model.connect("complement", "out", "filtered", "in2")
+model.connect("filtered", "out", "delay", "in")
-# 3. Create the simulator
+# 3. Simulate the model
sim_cfg = SimulationConfig(dt=0.05, T=30.)
sim = Simulator(model, sim_cfg)
+logs = sim.run(logging=["noise.outputs.out", "filtered.outputs.out"])
-# 4. Run the simulation
-logs = sim.run(logging=[
- "x.outputs.out",
- "v.outputs.out",
- ]
-)
-
-# 5. Plot the results
+# 4. Plot the results
plot_cfg = PlotConfig([
- {"title": "Position and Velocity",
- "signals": ["x.outputs.out", "v.outputs.out"],},
+ {"title": "Noisy signal vs Filtered",
+ "signals": ["noise.outputs.out", "filtered.outputs.out"],},
])
plot_from_config(logs, plot_cfg)
```
-The simulated position and velocity exhibit the expected damped oscillatory behavior.
+The resulting plot should look like this:
-
+
-See [examples/quick_start/oscillator.py](./examples/quick_start/oscillator.py)
+See [examples/quick_start/filter.py](./examples/quick_start/filter.py)
to run the example yourself.
### Graphical Editor
-The same model can be constructed visually using the graphical editor:
-
-
+The exact same model can be constructed visually using the graphical editor (as
+shown in the image above of this README).
To open the graphical editor, run:
```bash
@@ -111,14 +106,24 @@ pysimblocks examples/quick_start/gui
```
The quick-start GUI project is stored in a single
-`examples/quick_start/gui/project.yaml` file.
+[examples/quick_start/gui/project.yaml](./examples/quick_start/gui/project.yaml) file.
+
+### Learning Resources
-### Tutorials
-See the [Getting Started Guide](./docs/User_Guide/getting_started.md) for
-tutorials on building your first simulation with pySimBlocks.
+#### Tutorials
-### Examples
+Three step-by-step tutorials are available detailed in the
+[guide](./docs/User_Guide/getting_started.md):
+
+ | | Tutorial | Description |
+ |---|---|---|
+ | 1 | [Python API](./docs/User_Guide/tutorial_1_python.md) | Build a closed-loop PI control system in pure Python |
+ | 2 | [GUI](./docs/User_Guide/tutorial_2_gui.md) | Build the same system visually with the graphical editor |
+ | 3 | [SOFA](./docs/User_Guide/tutorial_3_sofa.md) | Replace the plant with a SOFA physics simulation |
+
+
+#### Other Examples
A collection of basic and advanced examples is available in the
[examples](./examples) directory, including:
@@ -134,11 +139,7 @@ See [examples/README.md](./examples/README.md) for an overview.
### License
-pySimBlocks is LGPL.
-
-LGPL refers to the GNU Lesser General Public License as published by the Free Software
-Foundation; either version 3.0 of the License, or (at your option) any later
-version.
+pySimBlocks is licensed under [LGPL-3.0-or-later](./LICENSE.md).
---
© 2026 Université de Lille & INRIA – Licensed under LGPL-3.0-or-later
diff --git a/docs/User_Guide/getting_started.md b/docs/User_Guide/getting_started.md
index 97474d2..f4e85cb 100644
--- a/docs/User_Guide/getting_started.md
+++ b/docs/User_Guide/getting_started.md
@@ -19,7 +19,7 @@ You will learn:
- How to run a discrete-time simulation
- How to log and visualize results
-→ [Start Tutorial 1 — Python API](tutorial_1_python.md)
+-> [Start Tutorial 1 — Python API](tutorial_1_python.md)
After this tutorial: You can build and run a basic closed-loop model from code.
## 2. First Simulation (GUI)
@@ -34,7 +34,7 @@ You will learn:
- How to configure blocks and simulation settings
- How to save and export the project
-→ [Start Tutorial 2 — GUI](tutorial_2_gui.md)
+-> [Start Tutorial 2 — GUI](tutorial_2_gui.md)
After this tutorial: You can create and manage equivalent models in the GUI.
## 3. SOFA Coupling
@@ -50,5 +50,5 @@ You will learn:
- How to run co-simulations
- How to use SOFA's GUI for inspection and debugging
-→ [Start Tutorial 3 — SOFA Coupling](tutorial_3_sofa.md)
+-> [Start Tutorial 3 — SOFA Coupling](tutorial_3_sofa.md)
After this tutorial: You can run a pySimBlocks + SOFA co-simulation workflow.
diff --git a/docs/User_Guide/images/gui_example.png b/docs/User_Guide/images/gui_example.png
index 5bf21ea..aee50a8 100644
Binary files a/docs/User_Guide/images/gui_example.png and b/docs/User_Guide/images/gui_example.png differ
diff --git a/docs/User_Guide/images/quick_example.png b/docs/User_Guide/images/quick_example.png
index 8d54ccf..f9ecdac 100644
Binary files a/docs/User_Guide/images/quick_example.png and b/docs/User_Guide/images/quick_example.png differ
diff --git a/docs/User_Guide/images/tutorial_2-block_dialog.gif b/docs/User_Guide/images/tutorial_2-block_dialog.gif
index 6e0fcf7..c0477d4 100644
Binary files a/docs/User_Guide/images/tutorial_2-block_dialog.gif and b/docs/User_Guide/images/tutorial_2-block_dialog.gif differ
diff --git a/docs/User_Guide/images/tutorial_2-connections.gif b/docs/User_Guide/images/tutorial_2-connections.gif
index 9ec0820..85a3ecf 100644
Binary files a/docs/User_Guide/images/tutorial_2-connections.gif and b/docs/User_Guide/images/tutorial_2-connections.gif differ
diff --git a/docs/User_Guide/images/tutorial_2-drag_drop.gif b/docs/User_Guide/images/tutorial_2-drag_drop.gif
index 310cbcf..a90355a 100644
Binary files a/docs/User_Guide/images/tutorial_2-drag_drop.gif and b/docs/User_Guide/images/tutorial_2-drag_drop.gif differ
diff --git a/docs/User_Guide/images/tutorial_2-plots.gif b/docs/User_Guide/images/tutorial_2-plots.gif
index beaa561..e93f1df 100644
Binary files a/docs/User_Guide/images/tutorial_2-plots.gif and b/docs/User_Guide/images/tutorial_2-plots.gif differ
diff --git a/docs/User_Guide/images/tutorial_2-setting.gif b/docs/User_Guide/images/tutorial_2-setting.gif
index 6e577af..ec84912 100644
Binary files a/docs/User_Guide/images/tutorial_2-setting.gif and b/docs/User_Guide/images/tutorial_2-setting.gif differ
diff --git a/docs/User_Guide/images/tutorial_3-run_psb_master.gif b/docs/User_Guide/images/tutorial_3-run_psb_master.gif
index 0355d86..21b3a96 100644
Binary files a/docs/User_Guide/images/tutorial_3-run_psb_master.gif and b/docs/User_Guide/images/tutorial_3-run_psb_master.gif differ
diff --git a/docs/User_Guide/images/tutorial_3-run_sofa_master.gif b/docs/User_Guide/images/tutorial_3-run_sofa_master.gif
index f91bdd8..1b24719 100644
Binary files a/docs/User_Guide/images/tutorial_3-run_sofa_master.gif and b/docs/User_Guide/images/tutorial_3-run_sofa_master.gif differ
diff --git a/docs/User_Guide/images/tutorial_3-sofa_block.gif b/docs/User_Guide/images/tutorial_3-sofa_block.gif
index b83ddcb..c917c7f 100644
Binary files a/docs/User_Guide/images/tutorial_3-sofa_block.gif and b/docs/User_Guide/images/tutorial_3-sofa_block.gif differ
diff --git a/docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif b/docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif
index 4366628..686e942 100644
Binary files a/docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif and b/docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif differ
diff --git a/docs/User_Guide/tutorial_1_python.md b/docs/User_Guide/tutorial_1_python.md
index b2ac9dd..98eb501 100644
--- a/docs/User_Guide/tutorial_1_python.md
+++ b/docs/User_Guide/tutorial_1_python.md
@@ -74,7 +74,7 @@ def main():
start_time=0.5
)
sum = Sum(name="error", signs="+-")
- pid = Pid(name="pid", controller="PI", Kp=Kp, Ki=Ki)
+ pid = Pid(name="PID", controller="PI", Kp=Kp, Ki=Ki)
system = LinearStateSpace(name="system", A=A, B=B, C=C, x0=x0)
# -------------------------------------------------------
@@ -86,8 +86,8 @@ def main():
model.connect("ref", "out", "error", "in1")
model.connect("system", "y", "error", "in2")
- model.connect("error", "out", "pid", "e")
- model.connect("pid", "u", "system", "u")
+ model.connect("error", "out", "PID", "e")
+ model.connect("PID", "u", "system", "u")
# -------------------------------------------------------
# 3. Create the simulator
@@ -102,7 +102,7 @@ def main():
# -------------------------------------------------------
logs = sim.run(logging=[
"ref.outputs.out",
- "pid.outputs.u",
+ "PID.outputs.u",
"system.outputs.y"
]
)
@@ -111,7 +111,7 @@ def main():
# 5. Extract logged data
# -------------------------------------------------------
t = sim.get_data("time")
- u = sim.get_data("pid.outputs.u").squeeze()
+ u = sim.get_data("PID.outputs.u").squeeze()
r = sim.get_data("ref.outputs.out").squeeze()
y = sim.get_data("system.outputs.y").squeeze()
@@ -187,5 +187,4 @@ This simple example is the foundation for more advanced use cases,
including:
- [GUI modeling](./tutorial_2_gui.md),
- [SOFA integration](./tutorial_3_sofa.md),
-- Hardware implementation.
diff --git a/docs/User_Guide/tutorial_2_gui.md b/docs/User_Guide/tutorial_2_gui.md
index a8f22e0..de229e1 100644
--- a/docs/User_Guide/tutorial_2_gui.md
+++ b/docs/User_Guide/tutorial_2_gui.md
@@ -88,7 +88,8 @@ Modify the following parameters:
| PID | Ki | 2. |
| Sum | signs | +- |
-Renaming a block updates its label in the `Diagram View`. In [Tutorial 1](./tutorial_1_python.md), the blocks were named `ref`, `error`, `pid`, and `system`.
+Renaming a block updates its label in the `Diagram View`. In
+[Tutorial 1](./tutorial_1_python.md), the blocks were named `ref`, `error`, `PID`, and `system`.
All required parameters must be defined before running the simulation.
@@ -104,7 +105,7 @@ All required parameters must be defined before running the simulation.
Next, configure the simulation settings:
- Sample time: 0.01 s
- Duration: 5 s
-- Signals to log: system y, pid u, and ref output
+- Signals to log: system y, PID u, and ref output
Click the `Settings` button in the `Toolbar`. A dialog opens with multiple panels. One is for the simulation.
@@ -186,4 +187,4 @@ After modifying the model, save the project and export a new `run.py` file.
Run it from the command line to verify that the exported script reproduces the same behavior.
This tutorial demonstrates how to build and execute a model visually.
-The next tutorials extend this approach to [SOFA integration](./tutorial_3_sofa.md) and real-time execution.
+The next tutorials extend this approach to [SOFA integration](./tutorial_3_sofa.md).
diff --git a/docs/User_Guide/tutorial_3_sofa.md b/docs/User_Guide/tutorial_3_sofa.md
index d1e17a9..a25bc86 100644
--- a/docs/User_Guide/tutorial_3_sofa.md
+++ b/docs/User_Guide/tutorial_3_sofa.md
@@ -16,16 +16,16 @@ The main objectives of this tutorial are to:
### 1.2 System Description
We build a simple closed-loop control system composed of three elements:
-- a constant reference,
-- a PI controller,
-- a SOFA simulation.
+- a constant reference
+- a PI controller
+- a SOFA simulation

The SOFA simulation is a finger actuated by a tendon.
The control input is the tendon tension, and the output is the vertical position of the fingertip.
The files for the SOFA scene can be found in the [tutorial_3_sofa
-folder](../../examples/tutorials/tutorial_3_sofa/finger).
+folder](../../examples/tutorials/tutorial_3_sofa/finger/).

@@ -40,9 +40,10 @@ your environment so that:
### 2.1 Install SOFA
-First, install SOFA from the [website](https://www.sofa-framework.org/download/) either:
-- Using the precompiled binaries (recommended for most users)
-- Building from source (advanced users). Be sure to have SofaPython3 plugin.
+
+Install SOFA from the [website](https://www.sofa-framework.org/download/) either
+using the precompiled binaries (recommended) or building from source (advanced
+users, requires the SofaPython3 plugin).
After installation, identify your main SOFA directory:
@@ -52,20 +53,55 @@ After installation, identify your main SOFA directory:
### 2.2 Set Environment Variables
-To enable coupling, you need to set the following environment variables:
-- `SOFA_ROOT`: Path to your SOFA installation
+> **Note:** The SofaPython3 site-packages path depends on your installation.
+> Check inside your SOFA directory if the exact sub-path differs from the examples below.
+
+The following environment variables must be set to enable coupling:
+- `SOFA_ROOT`: points to the main SOFA directory
+- `PYTHONPATH`: includes the path to the SOFA Python bindings
+
+The setup process differs slightly between operating systems and SOFA
+installation methods.
+
+#### 2.2.1 Linux / MacOS
+
+Do the following in your terminal (or add to your shell profile for
+persistence):
```bash
export SOFA_ROOT=/path/to/your/sofa
+
+# Binary release:
+export PYTHONPATH=$SOFA_ROOT/plugins/SofaPython3/lib/python3/site-packages:$PYTHONPATH
+
+# Built from source:
+# export PYTHONPATH=$SOFA_ROOT/lib/python3/site-packages:$PYTHONPATH
```
-- `PYTHONPATH`: Include the SOFA Python bindings site-packages.
- - If you installed SOFA from binaries, this is typically:
- ```bash
- export PYTHONPATH=$SOFA_ROOT/plugins/SofaPython3/lib/python3/site-packages:$PYTHONPATH
- ```
- - If you built SOFA from source, it may be:
- ```bash
- export PYTHONPATH=$SOFA_ROOT/lib/python3/site-packages:$PYTHONPATH
- ```
+
+> **Tip (Linux/MacOS):** To make these variables permanent, add the lines above
+> to your shell profile (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`), then
+> restart your terminal or run `source ~/.bashrc` (or the appropriate file) to
+> apply the changes.
+
+#### 2.2.2 Windows
+
+Set the environment variables in PowerShell (or add to your profile for
+persistence):
+
+```powershell
+$env:SOFA_ROOT = "C:\path\to\your\sofa"
+
+# Binary release:
+$env:PYTHONPATH = "$env:SOFA_ROOT\plugins\SofaPython3\lib\python3\site-packages;$env:PYTHONPATH"
+
+# Built from source:
+# $env:PYTHONPATH = "$env:SOFA_ROOT\lib\python3\site-packages;$env:PYTHONPATH"
+```
+
+> **Tip (Windows):** To make these variables permanent, you can either add the
+> lines above to your PowerShell profile (`$PROFILE`) — open it with
+> `notepad $PROFILE` — or set them via **System Properties → Advanced →
+> Environment Variables**.
+
### 2.3 Verify the Setup
@@ -78,9 +114,9 @@ import SofaRuntime
This should work without errors.
Verify that `runSofa` can be launched from the command line:
-```bash
-$SOFA_ROOT/bin/runSofa
-```
+
+- **Linux / macOS:** `$SOFA_ROOT/bin/runSofa`
+- **Windows:** `$env:SOFA_ROOT\bin\runSofa.exe`
Ensure `SofaPython3` (and other plugins used by the scene) are available.
@@ -97,7 +133,7 @@ To enable data exchange, a custom SOFA controller must be defined by subclassing
Your subclass **must** define the following attributes (typically in `__init__`):
-- `self.project_yaml` — path to the pySimblocks project YAML file.
+- `self.project_yaml` — string path to the pySimblocks project YAML file.
- `self.dt` — the simulation timestep (usually `root.dt.value`)
- `self.inputs` — a dictionary mapping input signal names to their current values (`None` initially)
- `self.outputs` — a dictionary mapping output signal names to their current values (`None` initially)
@@ -134,7 +170,7 @@ class FingerController(SofaPysimBlocksController):
self.actuator = actuator
self.tip_index = tip_index
self.dt = root.dt.value
- self.verbose = True
+ self.verbose = False # set to True to print debug info at each step
# Inputs & outputs dictionaries
self.inputs = { "cable": None }
@@ -184,10 +220,10 @@ you configure — in this tutorial, one input (cable) and one output (measure).
The dialog box exposes the following parameters:
| Parameter | Description | Example |
|---|---|---|
-| `scene_file` | Path to the SOFA scene file, relative to the project folder | `./sofa_scene.py` |
+| `scene_file` | Path to the SOFA scene file, relative to the project folder | `finger/Finger.py` |
| `input_keys` | Names of the input signals sent to SOFA | `["cable"]` |
| `output_keys` | Names of the output signals received from SOFA | `["measure"]` |
-| `sample_time` | Execution period of the block. If omitted, the global timestep is used | *(optional)* |
+| `sample_time` | Execution period of the block. (simulator one by default) | *(optional)* |
> **Note:** The key names must match exactly the keys defined in self.inputs and
> self.outputs in your SOFA controller. A mismatch will raise a runtime error.
@@ -211,7 +247,8 @@ At each step, the block sends the current control inputs to SOFA, triggers one
simulation step, and retrieves the updated outputs. From the diagram's point of
view, the SOFA plant behaves like any other discrete-time block.
-To run in this mode, simply run the diagram as usual from the GUI or command line.
+To run in this mode, simply run the diagram from the GUI or command line as in
+[Tutorial 2](./tutorial_2_gui.md).

@@ -229,8 +266,8 @@ physics step is resolved.
-A single controller instance manages the entire block diagram — there is one
-controller for the whole pySimBlocks model.
+A single controller instance, containing the pySimBlocks model, manages the
+entire block diagram.
To run in this mode, open the **SOFA** panel in the Toolbar and click
**runSofa**. SOFA launches with its graphical interface, and the coupled
@@ -269,7 +306,10 @@ slider range:
{'ref.value': [-10.0, 50.0], 'PID.Kp': [0.01, 3.0], 'PID.Ki': [0.01, 3.0]}
```
-In this example, three sliders are created: one to adjust the reference value `ref.value`, one to adjust the proportional gain `Kp` of the block named `pid`, and one for the integral gain `Ki`. All can be modified live from the SOFA GUI while the simulation runs.
+In this example, three sliders are created: one to adjust the reference value
+`ref.value`, one to adjust the proportional gain `Kp` of the block named `PID`,
+and one for the integral gain `Ki`. All can be modified live from the SOFA GUI
+while the simulation runs.
> **Tip:** The block name must match exactly the name given to the block in the
> diagram. The parameter name must match the parameter key as defined in the
@@ -277,7 +317,7 @@ In this example, three sliders are created: one to adjust the reference value `r
### 5.2 Live Signal Plots
-Plots defined in the pySimBlocks **Settings → Plots** panel (as in
+Plots defined in the pySimBlocks **Settings -> Plots** panel (as in
[Tutorial 2](./tutorial_2_gui.md)) are automatically forwarded to the SOFA GUI
and displayed as live charts that update at each simulation step.
@@ -289,7 +329,7 @@ mode with the Compliance Robotics GUI.
The GIF below shows the full workflow:
- the definition of the sliders in the `SofaPlant` block parameters,
-- the defintion of the plots in the pySimBlocks settings
+- the definition of the plots in the pySimBlocks settings
- the SOFA simulation running with the Compliance Robotics GUI,
- live slider adjustment of the reference and PI gains,
- the real-time plot of the fingertip position tracking the reference.
@@ -310,4 +350,3 @@ Experiment with the model to better understand the pySimBlocks–SOFA workflow:
- If using the Compliance Robotics GUI, adjust the gains and reference live using the sliders while the simulation is running.
This tutorial demonstrates how to couple pySimBlocks with SOFA for co-simulation.
-The next tutorial extends this approach to hardware-in-the-loop control.
diff --git a/examples/advanced/sofa/gui/finger/FingerController.py b/examples/advanced/sofa/gui/finger/FingerController.py
index e04ab55..c589e3c 100644
--- a/examples/advanced/sofa/gui/finger/FingerController.py
+++ b/examples/advanced/sofa/gui/finger/FingerController.py
@@ -14,7 +14,7 @@ class FingerController(SofaPysimBlocksController):
def __init__(self, root, actuator, mo, tip_index=121, name="FingerController"):
super().__init__(root, name=name)
- self.project_yaml = str((BASE_DIR / "../sofa_plant/project.yaml").resolve())
+ self.project_yaml = str((BASE_DIR / '../sofa_plant/project.yaml').resolve())
self.mo = mo
self.actuator = actuator
diff --git a/examples/quick_start/filter.py b/examples/quick_start/filter.py
new file mode 100644
index 0000000..298a746
--- /dev/null
+++ b/examples/quick_start/filter.py
@@ -0,0 +1,35 @@
+from pySimBlocks import Model, Simulator, SimulationConfig, PlotConfig
+from pySimBlocks.blocks.operators import Gain, Sum, Delay
+from pySimBlocks.blocks.sources import WhiteNoise
+from pySimBlocks.project.plot_from_config import plot_from_config
+
+# 1. Create the blocks
+noise = WhiteNoise(name="noise", std=1.0)
+delay = Delay(name="delay")
+filtered = Sum("filtered", signs="++")
+alpha_gain = Gain(name="alpha", gain=0.1)
+complement = Gain(name="complement", gain=0.9)
+
+# 2. Build the model
+model = Model("Example")
+for block in [noise, delay, filtered, alpha_gain, complement]:
+ model.add_block(block)
+
+model.connect("noise", "out", "alpha", "in")
+model.connect("delay", "out", "complement", "in")
+model.connect("alpha", "out", "filtered", "in1")
+model.connect("complement", "out", "filtered", "in2")
+model.connect("filtered", "out", "delay", "in")
+
+# 3. Simulate the model
+sim_cfg = SimulationConfig(dt=0.05, T=30.)
+sim = Simulator(model, sim_cfg)
+logs = sim.run(logging=["noise.outputs.out", "filtered.outputs.out"])
+
+# 4. Plot the results
+plot_cfg = PlotConfig([
+ {"title": "Noisy signal vs Filtered",
+ "signals": ["noise.outputs.out", "filtered.outputs.out"],},
+ ])
+plot_from_config(logs, plot_cfg)
+
diff --git a/examples/quick_start/gui/project.yaml b/examples/quick_start/gui/project.yaml
index 9ea4d0c..7fe1a20 100644
--- a/examples/quick_start/gui/project.yaml
+++ b/examples/quick_start/gui/project.yaml
@@ -9,106 +9,97 @@ simulation:
T: 30.0
solver: fixed
logging:
- - v.outputs.out
- - x.outputs.out
+ - noise.outputs.out
+ - filtered.outputs.out
plots:
- - title: Position and Velocity
+ - title: Noisy signal vs Filtered
signals:
- - v.outputs.out
- - x.outputs.out
+ - noise.outputs.out
+ - filtered.outputs.out
diagram:
blocks:
- - name: v
- category: operators
- type: discrete_integrator
+ - name: noise
+ category: sources
+ type: white_noise
parameters:
- initial_state: 5.0
- method: euler forward
- - name: x
+ std: 1.0
+ - name: alpha
category: operators
- type: discrete_integrator
+ type: gain
parameters:
- initial_state: 2.0
- method: euler forward
- - name: Sum
+ gain: 0.1
+ multiplication: Element wise (K * u)
+ - name: filtered
category: operators
type: sum
parameters:
- signs: --
- - name: damping
+ signs: ++
+ - name: Delay
category: operators
- type: gain
+ type: delay
parameters:
- gain: 0.5
- multiplication: Element wise (K * u)
- - name: stiffness
+ num_delays: 1
+ - name: 1-alpha
category: operators
type: gain
parameters:
- gain: 2.0
+ gain: 0.9
multiplication: Element wise (K * u)
connections:
- name: c1
ports:
- - Sum.out
- - v.in
+ - filtered.out
+ - Delay.in
- name: c2
ports:
- - v.out
- - x.in
+ - noise.out
+ - alpha.in
- name: c3
ports:
- - v.out
- - damping.in
+ - 1-alpha.out
+ - filtered.in2
- name: c4
ports:
- - x.out
- - stiffness.in
+ - alpha.out
+ - filtered.in1
- name: c5
ports:
- - damping.out
- - Sum.in2
- - name: c6
- ports:
- - stiffness.out
- - Sum.in1
+ - Delay.out
+ - 1-alpha.in
gui:
layout:
blocks:
- v:
- x: -570.0
- y: -300.0
+ noise:
+ x: -1105.0
+ y: -385.0
+ orientation: normal
+ width: 150.0
+ height: 60.0
+ alpha:
+ x: -895.0
+ y: -385.0
+ orientation: normal
+ width: 120.0
+ height: 60.0
+ filtered:
+ x: -670.0
+ y: -375.0
orientation: normal
width: 120.0
height: 60.0
- x:
- x: -370.0
- y: -300.0
+ Delay:
+ x: -490.0
+ y: -365.0
orientation: normal
width: 120.0
height: 60.0
- Sum:
- x: -740.0
- y: -300.0
+ 1-alpha:
+ x: -895.0
+ y: -310.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:
- c4:
- route: [[-235.0, -270.0], [-225.0, -270.0], [-225.0, -322.5], [-225.0, -322.5],
- [-225.0, -377.5], [-414.0, -377.5]]
- c6:
- 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]]
+ c5:
+ route: [[-355.0, -335.0], [-347.0, -335.0], [-347.0, -215.0], [-909.0, -215.0],
+ [-909.0, -280.0], [-901.0, -280.0]]
diff --git a/examples/quick_start/oscillator.py b/examples/quick_start/oscillator.py
deleted file mode 100644
index bef36ea..0000000
--- a/examples/quick_start/oscillator.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from pySimBlocks import Model, Simulator, SimulationConfig, PlotConfig
-from pySimBlocks.blocks.operators import Gain, Sum, DiscreteIntegrator
-from pySimBlocks.project.plot_from_config import plot_from_config
-
-# 1. Create the blocks
-v = DiscreteIntegrator("v", initial_state=5)
-x = DiscreteIntegrator("x", initial_state=2.)
-damping = Gain(name="damping", gain=0.5)
-stiffness = Gain(name="stiffness", gain=2)
-sum = Sum(name="sum", signs="--")
-
-# 2. Build the model
-model = Model("Example")
-for block in [v, x, damping, stiffness, sum]:
- model.add_block(block)
-
-model.connect("v", "out", "x", "in")
-model.connect("v", "out", "damping", "in")
-model.connect("x", "out", "stiffness", "in")
-model.connect("damping", "out", "sum", "in1")
-model.connect("stiffness", "out", "sum", "in2")
-model.connect("sum", "out", "v", "in")
-
-# 3. Create the simulator
-sim_cfg = SimulationConfig(dt=0.05, T=30.)
-sim = Simulator(model, sim_cfg)
-
-# 4. Run the simulation
-logs = sim.run(logging=[
- "x.outputs.out",
- "v.outputs.out",
- ]
-)
-
-# 5. Plot the results
-plot_cfg = PlotConfig([
- {"title": "Position and Velocity",
- "signals": ["x.outputs.out", "v.outputs.out"],},
- ])
-plot_from_config(logs, plot_cfg)
-
diff --git a/examples/tutorials/tutorial_1_python/main.py b/examples/tutorials/tutorial_1_python/main.py
index e9037b0..534da83 100644
--- a/examples/tutorials/tutorial_1_python/main.py
+++ b/examples/tutorials/tutorial_1_python/main.py
@@ -32,7 +32,7 @@ def main():
start_time=0.5
)
sum = Sum(name="error", signs="+-")
- pid = Pid(name="pid", controller="PI", Kp=Kp, Ki=Ki)
+ pid = Pid(name="PID", controller="PI", Kp=Kp, Ki=Ki)
system = LinearStateSpace(name="system", A=A, B=B, C=C, x0=x0)
# -------------------------------------------------------
@@ -44,8 +44,8 @@ def main():
model.connect("ref", "out", "error", "in1")
model.connect("system", "y", "error", "in2")
- model.connect("error", "out", "pid", "e")
- model.connect("pid", "u", "system", "u")
+ model.connect("error", "out", "PID", "e")
+ model.connect("PID", "u", "system", "u")
# -------------------------------------------------------
# 3. Create the simulator
@@ -60,7 +60,7 @@ def main():
# -------------------------------------------------------
logs = sim.run(logging=[
"ref.outputs.out",
- "pid.outputs.u",
+ "PID.outputs.u",
"system.outputs.y"
]
)
@@ -69,7 +69,7 @@ def main():
# 5. Extract logged data
# -------------------------------------------------------
t = sim.get_data("time")
- u = sim.get_data("pid.outputs.u").squeeze()
+ u = sim.get_data("PID.outputs.u").squeeze()
r = sim.get_data("ref.outputs.out").squeeze()
y = sim.get_data("system.outputs.y").squeeze()
diff --git a/examples/tutorials/tutorial_3_sofa/finger/Finger.py b/examples/tutorials/tutorial_3_sofa/finger/Finger.py
index 43f53f1..8024b2d 100644
--- a/examples/tutorials/tutorial_3_sofa/finger/Finger.py
+++ b/examples/tutorials/tutorial_3_sofa/finger/Finger.py
@@ -17,6 +17,7 @@ def createScene(rootNode):
rootNode.addObject('RequiredPlugin', pluginName=[
"Sofa.Component.AnimationLoop", # Needed to use components FreeMotionAnimationLoop
"Sofa.Component.Constraint.Lagrangian.Correction", # Needed to use components GenericConstraintCorrection
+ "Sofa.Component.LinearSolver.Iterative",
"Sofa.Component.Constraint.Lagrangian.Solver", # Needed to use components GenericConstraintSolver
"Sofa.Component.Engine.Select", # Needed to use components BoxROI
"Sofa.Component.IO.Mesh", # Needed to use components MeshSTLLoader, MeshVTKLoader
@@ -38,7 +39,16 @@ def createScene(rootNode):
rootNode.addObject('FreeMotionAnimationLoop')
rootNode.addObject('DefaultVisualManagerLoop')
- rootNode.addObject('GenericConstraintSolver', tolerance=1e-5, maxIterations=100)
+ try: # Compatible with SOFA <= 25.06
+ rootNode.addObject('GenericConstraintSolver', tolerance=1e-5, maxIterations=100)
+ except Exception as e: # Fallback for SOFA >= 25.12
+ print("GenericConstraintSolver not available, falling back to CGLinearSolver")
+ try:
+ rootNode.addObject('CGLinearSolver', name='solver', iterations=500, tolerance=1e-10, threshold=1e-10)
+ rootNode.addObject('BlockGaussSeidelConstraintSolver', maxIterations=100, tolerance=1e-5)
+ except Exception as e: # Error in both versions
+ print("Error adding CGLinearSolver:", e)
+ raise e
rootNode.gravity = [0, -9810, 0]
rootNode.dt = 0.01
diff --git a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py
index 613f234..f5078c9 100644
--- a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py
+++ b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py
@@ -11,13 +11,13 @@ class FingerController(SofaPysimBlocksController):
def __init__(self, root, actuator, mo, tip_index=121, name="FingerController"):
super().__init__(root, name=name)
- self.project_yaml = str((BASE_DIR / "../project.yaml").resolve())
+ 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 = True
+ self.verbose = False # Set to True to print debug information at each step
# Inputs & outputs dictionaries
self.inputs = { "cable": None }
diff --git a/pySimBlocks/blocks/operators/algebraic_function.py b/pySimBlocks/blocks/operators/algebraic_function.py
index 7669dfa..49ba9fd 100644
--- a/pySimBlocks/blocks/operators/algebraic_function.py
+++ b/pySimBlocks/blocks/operators/algebraic_function.py
@@ -108,7 +108,7 @@ def adapt_params(cls,
)
# --- 2. Resolve file path (relative to project.yaml directory)
- path = Path(file_path)
+ path = Path(file_path).expanduser()
if not path.is_absolute():
path = (params_dir / path).resolve()
diff --git a/pySimBlocks/blocks/sources/chirp.py b/pySimBlocks/blocks/sources/chirp.py
index 654a1d7..1dd01b7 100644
--- a/pySimBlocks/blocks/sources/chirp.py
+++ b/pySimBlocks/blocks/sources/chirp.py
@@ -28,8 +28,8 @@ class Chirp(BlockSource):
Multi-dimensional chirp signal source (linear or logarithmic).
mode:
- "linear" → linear frequency sweep
- "log" → logarithmic (exponential) sweep
+ "linear" -> linear frequency sweep
+ "log" -> logarithmic (exponential) sweep
"""
VALID_MODES = {"linear", "log"}
diff --git a/pySimBlocks/blocks/sources/file_source.py b/pySimBlocks/blocks/sources/file_source.py
index 0263662..1d80da8 100644
--- a/pySimBlocks/blocks/sources/file_source.py
+++ b/pySimBlocks/blocks/sources/file_source.py
@@ -95,7 +95,7 @@ def adapt_params(
if file_path is None:
return adapted
- path = Path(file_path)
+ path = Path(file_path).expanduser()
if not path.is_absolute() and params_dir is not None:
path = (params_dir / path).resolve()
diff --git a/pySimBlocks/blocks/sources/function_source.py b/pySimBlocks/blocks/sources/function_source.py
index d281b85..5262a84 100644
--- a/pySimBlocks/blocks/sources/function_source.py
+++ b/pySimBlocks/blocks/sources/function_source.py
@@ -91,7 +91,7 @@ def adapt_params(
"FunctionSource adapter requires both 'file_path' and 'function_name'."
)
- path = Path(adapted["file_path"])
+ path = Path(adapted["file_path"]).expanduser()
if not path.is_absolute() and params_dir is not None:
path = (params_dir / path).resolve()
diff --git a/pySimBlocks/blocks/systems/non_linear_state_space.py b/pySimBlocks/blocks/systems/non_linear_state_space.py
index f88996b..b2b9d14 100644
--- a/pySimBlocks/blocks/systems/non_linear_state_space.py
+++ b/pySimBlocks/blocks/systems/non_linear_state_space.py
@@ -121,7 +121,7 @@ def adapt_params(cls,
)
# --- 2. Resolve file path (relative to project.yaml directory)
- path = Path(file_path)
+ path = Path(file_path).expanduser()
if not path.is_absolute():
path = (params_dir / path).resolve()
diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py
index a87091e..ec64d9a 100644
--- a/pySimBlocks/blocks/systems/sofa/sofa_controller.py
+++ b/pySimBlocks/blocks/systems/sofa/sofa_controller.py
@@ -34,8 +34,6 @@
except ImportError:
_imgui = False
-print(f"SOFA ImGui support: {_imgui}")
-
class SofaPysimBlocksController(Sofa.Core.Controller):
"""
@@ -89,6 +87,7 @@ def __init__(self, root: Sofa.Core.Node, name: str ="SofaControllerGui"):
self.step_index: int = 0
self.project_yaml: str | None = None
+ self._init_failed = False
# --------------------------------------------------------------------------
# Public methods
@@ -114,7 +113,7 @@ def set_inputs(self):
Apply inputs from pySimBlocks to SOFA components.
Must be implemented by child classes.
"""
- raise NotImplementedError("set_inputs() must be implemented by subclass.")
+ raise NotImplementedError("[pySimBlocks] ERROR: set_inputs() must be implemented by subclass.")
# ------------------------------------------------------------------
def get_outputs(self):
@@ -123,7 +122,7 @@ def get_outputs(self):
MUST ALWAYS WORK AND RETURN CONSISTENT SHAPES.
Must be implemented by child classes.
"""
- raise NotImplementedError("get_outputs() must be implemented by subclass.")
+ raise NotImplementedError("[pySimBlocks] ERROR: get_outputs() must be implemented by subclass.")
# --- Optionnal methods. ---
def save(self):
@@ -155,9 +154,9 @@ def get_block(self, block_name: str):
If the model is not built or if the block is not found.
"""
if self.sim is None:
- raise RuntimeError("Simulator not initialized. Cannot get block.")
+ raise RuntimeError("[pySimBlocks] ERROR: Simulator not initialized. Cannot get block.")
if block_name not in self.sim.model.blocks:
- raise RuntimeError(f"Block '{block_name}' not found in the model.")
+ raise RuntimeError(f"[pySimBlocks] ERROR: Block '{block_name}' not found in the model.")
return self.sim.model.blocks[block_name]
# ----------------------------------------------------------------------
@@ -168,13 +167,16 @@ def onAnimateBeginEvent(self, event):
SOFA callback executed before each physical integration step.
Sequence:
- 1. Read SOFA outputs → get_outputs()
+ 1. Read SOFA outputs -> get_outputs()
2. Push them into the exchange block
- 3. Advance pySimBlocks one step → sim.step()
+ 3. Advance pySimBlocks one step -> sim.step()
4. Retrieve controller inputs from exchange block
- 5. Apply them to SOFA → set_inputs()
+ 5. Apply them to SOFA -> set_inputs()
"""
if self.SOFA_MASTER:
+ if self._init_failed:
+ return
+
if self.sim is None:
self._prepare_pysimblocks()
self._get_sofa_outputs()
@@ -220,7 +222,7 @@ def _build_model(self):
"""
project_path = self.project_yaml
if project_path is None:
- raise RuntimeError("SOFA_MASTER=True requires project_yaml to be set.")
+ raise RuntimeError("[pySimBlocks] ERROR: SOFA_MASTER=True requires project_yaml to be set.")
self.sim_cfg, model_dict, self.plot_cfg, _, params_dir = load_project_config(project_path)
model_dict = self._adapt_model_for_sofa(model_dict)
@@ -234,26 +236,63 @@ def _prepare_pysimblocks(self):
Called once SOFA is initialized AND if SOFA is the master.
Initialize the pysimblock struture.
"""
- if self.SOFA_MASTER and self.project_yaml is None:
- raise RuntimeError("SOFA_MASTER=True requires project_yaml.")
- if self.dt is None:
- raise ValueError("Sample time dt Must be set at initialization.")
+ try:
+ 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()
+ self.sim = Simulator(self.model, self.sim_cfg, verbose=self.verbose)
+ self._get_sofa_outputs()
+ self.sim.initialize()
+ self.sim_index = 0
+
+ ratio = self.sim_cfg.dt / self.dt
+ if abs(ratio - round(ratio)) > 1e-12:
+ self._init_failed = True
+ raise ValueError(
+ "[pySimBlocks] ERROR: Sample time mismatch.\n"
+ f"pySimBlocks sample time={self.sim_cfg.dt} "
+ f"is not a multiple of Sofa sample time={self.dt}."
+ )
+ self.ratio = int(round(ratio))
+ self.counter = 0
+ except Exception as e:
+ self._init_failed = True
+ # print(f"Initialization failed: {e}")
+ raise
- self._build_model()
- self._detect_sofa_exchange_block()
- self.sim = Simulator(self.model, self.sim_cfg, verbose=self.verbose)
- self._get_sofa_outputs()
- self.sim.initialize()
- self.sim_index = 0
+ # ------------------------------------------------------------------
+ def _secure_keys(self):
+ """
+ Ensure that the keys used for SOFA exchange are consistent between the model and the controller.
+ """
+ model_inputs_keys = set(self._sofa_block.inputs.keys())
+ sofa_inputs_keys = set(self.inputs.keys())
+ if not model_inputs_keys.issubset(sofa_inputs_keys):
+ self._init_failed = True
+ raise RuntimeError(
+ "[pySimBlocks] ERROR: model input_keys are missing from controller inputs.\n"
+ f"SOFA controller inputs: {sofa_inputs_keys}\n"
+ f"Model block inputs: {model_inputs_keys}\n"
+ f"Ensure that the controller in the SOFA block contains at least the same input keys as the SofaExchangeIO block."
+ )
- ratio = self.sim_cfg.dt / self.dt
- if abs(ratio - round(ratio)) > 1e-12:
- raise ValueError(
- f"pySimBlocks sample time={self.sim_cfg.dt} "
- f"is not a multiple of Sofa sample time={self.dt}."
- )
- self.ratio = int(round(ratio))
- self.counter = 0
+ model_outputs_keys = set(self._sofa_block.outputs.keys())
+ sofa_outputs_keys = set(self.outputs.keys())
+ if not model_outputs_keys.issubset(sofa_outputs_keys):
+ self._init_failed = True
+ raise RuntimeError(
+ "[pySimBlocks] ERROR: model output_keys are missing from controller outputs.\n"
+ f"SOFA controller outputs: {sofa_outputs_keys}\n"
+ f"Model block outputs: {model_outputs_keys}\n"
+ f"Ensure that the controller in the SOFA block contains at least the same output keys as the SofaExchangeIO block."
+ )
# ------------------------------------------------------------------
@@ -278,14 +317,16 @@ def _detect_sofa_exchange_block(self):
candidates = [blk for blk in self.model.blocks.values() if isinstance(blk, SofaExchangeIO)]
if len(candidates) == 0:
+ self._init_failed = True
raise RuntimeError(
- "No SofaExchangeIO block found in the model. "
+ "[pySimBlocks] ERROR: No SofaExchangeIO block found in the model.\n"
"The controller must include exactly one SOFA exchange block."
)
if len(candidates) > 1:
+ self._init_failed = True
raise RuntimeError(
- f"Multiple SofaExchangeIO blocks found ({len(candidates)}). "
+ "[pySimBlocks] ERROR: Multiple SofaExchangeIO blocks found ({len(candidates)}).\n"
"Only one SOFA IO block is allowed."
)
@@ -318,7 +359,7 @@ def _set_sofa_plot(self):
return
if self.sim is None:
- raise RuntimeError("Simulator not initialized.")
+ raise RuntimeError("[pySimBlocks] ERROR: Simulator not initialized.")
self._plot_node = self.root.addChild("PLOT")
self._plot_data = {}
@@ -356,7 +397,7 @@ def _set_sofa_slider(self):
return
if self.sim is None:
- raise RuntimeError("Simulator not initialized.")
+ raise RuntimeError("[pySimBlocks] ERROR: Simulator not initialized.")
data = self._sofa_block.slider_params
data = data if data is not None else {}
diff --git a/pySimBlocks/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/blocks/systems/sofa/sofa_plant.py
index 11e9386..83b8e39 100644
--- a/pySimBlocks/blocks/systems/sofa/sofa_plant.py
+++ b/pySimBlocks/blocks/systems/sofa/sofa_plant.py
@@ -42,6 +42,35 @@ def sofa_worker(conn, scene_file, input_keys, output_keys):
root = Sofa.Core.Node("root")
root, controller = mod.createScene(root)
+
+ sofa_outputs_keys = set(controller.outputs.keys())
+ if not set(output_keys).issubset(sofa_outputs_keys):
+ conn.send({
+ "cmd": "error",
+ "message": (
+ f"\n[pySimBlocks] ERROR: Output key not found in controller outputs.\n"
+ f"Available keys: {sofa_outputs_keys}\n"
+ f"Provided keys: {set(output_keys)}\n"
+ f"Check the 'output_keys' parameter in your project.yaml."
+ )
+ })
+ conn.close()
+ return
+
+ sofa_inputs_keys = set(controller.inputs.keys())
+ if not set(input_keys).issubset(sofa_inputs_keys):
+ conn.send({
+ "cmd": "error",
+ "message": (
+ f"[pySimBlocks] ERROR: Input key not found in controller inputs.\n"
+ f"Available keys: {sofa_inputs_keys}\n"
+ f"Provided keys: {set(input_keys)}\n"
+ f"Check the 'input_keys' parameter in your project.yaml."
+ )
+ })
+ conn.close()
+ return
+
controller.SOFA_MASTER = False
Sofa.Simulation.initRoot(root)
@@ -54,26 +83,39 @@ def sofa_worker(conn, scene_file, input_keys, output_keys):
Sofa.Simulation.animate(root, dt)
# Send initial outputs
- controller.get_outputs()
- initial = {k: np.asarray(controller.outputs[k]).reshape(-1,1) for k in output_keys}
- conn.send(initial)
+ try:
+ controller.get_outputs()
+ initial = {k: np.asarray(controller.outputs[k]).reshape(-1,1) for k in output_keys}
+ conn.send(initial)
+ except Exception as e:
+ conn.send({
+ "cmd": "error",
+ "message":
+ f"[pySimBlocks] ERROR: Failed to get initial outputs.\n{e}"})
+ conn.close()
+ return
# 2. Main loop
while True:
msg = conn.recv()
if msg["cmd"] == "step":
- # apply inputs
- for key, val in msg["inputs"].items():
- controller.inputs[key] = val
-
- controller.set_inputs()
- Sofa.Simulation.animate(root, dt)
- controller.get_outputs()
-
- outputs = {k: np.asarray(controller.outputs[k]).reshape(-1,1)
- for k in output_keys}
- conn.send(outputs)
+ try:
+ for key, val in msg["inputs"].items():
+ controller.inputs[key] = val
+
+ controller.set_inputs()
+ Sofa.Simulation.animate(root, dt)
+ controller.get_outputs()
+
+ outputs = {k: np.asarray(controller.outputs[k]).reshape(-1,1)
+ for k in output_keys}
+ conn.send(outputs)
+ except Exception as e:
+ conn.send({
+ "cmd": "error",
+ "message": f"[pySimBlocks] ERROR during step execution.\n{e}"})
+ break
elif msg["cmd"] == "stop":
break
@@ -159,7 +201,7 @@ def adapt_params(cls,
if scene_file is None:
raise ValueError("Missing 'scene_file' parameter")
- path = Path(scene_file)
+ path = Path(scene_file).expanduser()
if not path.is_absolute():
path = (params_dir / path).resolve()
@@ -188,6 +230,9 @@ def initialize(self, t0: float):
# Receive initial outputs
initial_outputs = self.conn.recv()
+ if isinstance(initial_outputs, dict) and initial_outputs.get("cmd") == "error":
+ raise RuntimeError(initial_outputs["message"])
+
for k in self.output_keys:
self.outputs[k] = initial_outputs[k]
self.state[k] = initial_outputs[k]
@@ -222,6 +267,8 @@ def state_update(self, t: float, dt: float):
# Receive outputs
outputs = self.conn.recv()
+ if isinstance(outputs, dict) and outputs.get("cmd") == "error":
+ raise RuntimeError(outputs["message"])
for k in self.output_keys:
self.next_state[k] = outputs[k]
diff --git a/pySimBlocks/core/model.py b/pySimBlocks/core/model.py
index 0dc80fc..8a6b330 100644
--- a/pySimBlocks/core/model.py
+++ b/pySimBlocks/core/model.py
@@ -144,10 +144,10 @@ def build_execution_order(self):
if block_dist.direct_feedthrough:
graph[src_block].append(dst_block)
indegree[dst_block] += 1
- vprint(f" DEPENDENCY: {src_block}.{src_port} → {dst_block}.{dst_port} "
+ vprint(f" DEPENDENCY: {src_block}.{src_port} -> {dst_block}.{dst_port} "
f"(direct-feedthrough)")
else:
- vprint(f" NO DEPENDENCY: {src_block}.{src_port} → {dst_block}.{dst_port} "
+ vprint(f" NO DEPENDENCY: {src_block}.{src_port} -> {dst_block}.{dst_port} "
f"(destination NOT direct-feedthrough)")
# Show resulting graph
@@ -177,7 +177,7 @@ def build_execution_order(self):
# Decrease indegree for successors
for succ in graph[current]:
indegree[succ] -= 1
- vprint(f" indegree[{succ}] → {indegree[succ]}")
+ vprint(f" indegree[{succ}] -> {indegree[succ]}")
if indegree[succ] == 0:
ready.append(succ)
vprint(f" '{succ}' added to READY")
diff --git a/pySimBlocks/gui/addons/sofa/sofa_dialog.py b/pySimBlocks/gui/addons/sofa/sofa_dialog.py
index 4b0a4f6..76e2111 100644
--- a/pySimBlocks/gui/addons/sofa/sofa_dialog.py
+++ b/pySimBlocks/gui/addons/sofa/sofa_dialog.py
@@ -130,8 +130,12 @@ def run(self):
))
progress.show()
- ok, title, details = self.sofa_service.run()
- progress.close()
+ try:
+ ok, title, details = self.sofa_service.run()
+ except Exception as e:
+ ok, title, details = False, "Error launching SOFA", str(e)
+ finally:
+ progress.close()
if not ok:
dialog = LogDialog(
title=f"SOFA error – {title}",
diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py
index 00e8701..063dbe9 100644
--- a/pySimBlocks/gui/addons/sofa/sofa_service.py
+++ b/pySimBlocks/gui/addons/sofa/sofa_service.py
@@ -45,6 +45,10 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr
self._detect_sofa()
+
+ # --------------------------------------------------------------------------
+ # Public methods
+ # --------------------------------------------------------------------------
def get_scene_file(self):
flag, msg, details = self.can_use_sofa()
if flag:
@@ -66,6 +70,7 @@ def get_scene_file(self):
else:
return flag, msg, details
+ # ------------------------------------------------------------------
def can_use_sofa(self):
sofa_block = [b for b in self.project_state.blocks if b.meta.type in ["sofa_plant", "sofa_exchange_i_o"]]
if len(sofa_block) == 0:
@@ -75,6 +80,7 @@ def can_use_sofa(self):
else:
return True, "Sofa can be master", "Only one system found. Diagram can be used from controller."
+ # ------------------------------------------------------------------
def export_controller(self, window, saver):
if window.confirm_discard_or_save("exporting sofa"):
saver.save(self.project_controller.project_state, self.project_controller.view.block_items)
@@ -82,6 +88,7 @@ def export_controller(self, window, saver):
raise ValueError("Project directory is not set.\nPlease define it in settings.")
generate_sofa_controller(self.project_state.directory_path)
+ # ------------------------------------------------------------------
def run(self):
env_ok, msg = self._check_sofa_environnment()
if not env_ok:
@@ -100,7 +107,11 @@ def run(self):
runtime_yaml = runtime_project_yaml_path(project_dir)
cleanup_runtime_project_yaml(project_dir)
save_yaml(project_state=self.project_state, runtime=True)
- generate_sofa_controller(project_yaml=runtime_yaml)
+ try:
+ generate_sofa_controller(project_yaml=runtime_yaml)
+ except Exception as e:
+ cleanup_runtime_project_yaml(project_dir)
+ return False, "Could not update SOFA controller", str(e)
# set command
plugins = "SofaPython3"
@@ -108,13 +119,20 @@ def run(self):
plugins += ",SofaImgui"
args = ["-l", plugins, "-g", self.gui, self.scene_file]
+ self._full_log = ""
+
self.process = QProcess()
env = QProcessEnvironment.systemEnvironment()
self.process.setProcessEnvironment(env)
+ self.process.setWorkingDirectory(str(Path(self.scene_file).parent))
self.process.setProgram(self.sofa_path)
self.process.setArguments(args)
self.process.setProcessChannelMode(QProcess.MergedChannels)
+ self.process.readyReadStandardOutput.connect(
+ lambda: self._accumulate_output()
+ )
+
try:
self.process.start()
if not self.process.waitForStarted():
@@ -127,17 +145,29 @@ def run(self):
return False, "Could not regenerate controller", "project.yaml does not exist.\n" + str(e)
# get output results
- output = self.process.readAllStandardOutput().data().decode()
- errors = self.process.readAllStandardError().data().decode()
- full_log = output + "\n" + errors
+ full_log = self._full_log
exit_code = self.process.exitCode()
if exit_code != 0:
return False, "SOFA exited with error", f"exit code = {exit_code}\n\n{full_log}"
-
+ pysimblocks_errors = [
+ line for line in full_log.splitlines()
+ if "[pySimBlocks] ERROR" in line
+ ]
+ if pysimblocks_errors:
+ return False, "pySimBlocks configuration error", "\n".join(pysimblocks_errors)
return True, "SOFA finished", "Process terminated correctly"
finally:
cleanup_runtime_project_yaml(project_dir)
+ # --------------------------------------------------------------------------
+ # Internal methods
+ # --------------------------------------------------------------------------
+ def _accumulate_output(self):
+ chunk = self.process.readAllStandardOutput().data().decode()
+ print(chunk, end="")
+ self._full_log += chunk
+
+ # ------------------------------------------------------------------
def _check_sofa_environnment(self):
sofa_root = os.environ.get("SOFA_ROOT")
if not sofa_root:
@@ -145,13 +175,17 @@ def _check_sofa_environnment(self):
return True, "OK"
+ # ------------------------------------------------------------------
def _detect_sofa(self):
detected = None
sofa_root = os.environ.get("SOFA_ROOT")
if sofa_root:
- potential_path = Path(sofa_root) / "bin" / "runSofa"
- if potential_path.exists():
- detected = str(potential_path)
+ bin_dir = Path(sofa_root) / "bin"
+ for candidate in ("runSofa", "runSofa.exe"):
+ potential_path = bin_dir / candidate
+ if potential_path.exists():
+ detected = str(potential_path)
+ break
if not detected:
detected = shutil.which("runSofa")
@@ -162,12 +196,13 @@ def _detect_sofa(self):
if detected:
self.sofa_path = detected
+ # ------------------------------------------------------------------
def _resolve_scene_file(self, scene_file: str) -> Path:
project_dir = self.project_state.directory_path
if project_dir is None:
raise RuntimeError("Project directory is not set")
- path = Path(scene_file)
+ path = Path(scene_file).expanduser()
if not path.is_absolute():
path = (project_dir / path).resolve()
diff --git a/pySimBlocks/gui/blocks/block_meta.py b/pySimBlocks/gui/blocks/block_meta.py
index 4f39c15..c3f4b25 100644
--- a/pySimBlocks/gui/blocks/block_meta.py
+++ b/pySimBlocks/gui/blocks/block_meta.py
@@ -370,9 +370,13 @@ def _browse_and_set_relative_file(
try:
relative_path = selected_path.relative_to(base_resolved)
except ValueError:
- relative_path = Path(os.path.relpath(str(selected_path), str(base_resolved)))
+ try:
+ relative_path = Path(os.path.relpath(str(selected_path), str(base_resolved)))
+ except ValueError:
+ # Windows cross-drive case (e.g. C: -> D:): keep absolute path.
+ relative_path = selected_path
- edit.setText(str(relative_path))
+ edit.setText(relative_path.as_posix())
# ------------------------------------------------------------
def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, readonly: bool,):
@@ -380,7 +384,7 @@ def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, r
return
if name == "name":
- session.instance.name = val
+ session.local_params["name"] = val
else:
text = str(val).strip()
try:
diff --git a/pySimBlocks/gui/dialogs/settings/project.py b/pySimBlocks/gui/dialogs/settings/project.py
index 8596809..45f6f04 100644
--- a/pySimBlocks/gui/dialogs/settings/project.py
+++ b/pySimBlocks/gui/dialogs/settings/project.py
@@ -112,9 +112,13 @@ def browse_external_file(self):
try:
relative_path = selected_path.relative_to(base_dir.resolve())
except ValueError:
- relative_path = Path(os.path.relpath(str(selected_path), str(base_dir.resolve())))
+ try:
+ relative_path = Path(os.path.relpath(str(selected_path), str(base_dir.resolve())))
+ except ValueError:
+ # Windows cross-drive case (e.g. C: -> D:): keep absolute path.
+ relative_path = selected_path
- self.external_edit.setText(str(relative_path))
+ self.external_edit.setText(relative_path.as_posix())
def browse_project_directory(self):
current_dir = Path(self.dir_edit.text()).expanduser()
@@ -132,6 +136,9 @@ def browse_project_directory(self):
self.dir_edit.setText(str(Path(selected_dir).resolve()))
def load_project(self):
+ main_window = self.settings_dialog.parent()
+ if not main_window.confirm_discard_or_save("loading a new project"):
+ return
self.apply()
self.project_controller.load_project(ProjectLoaderYaml())
ext = self.project_state.external
diff --git a/pySimBlocks/gui/dialogs/unsaved_dialog.py b/pySimBlocks/gui/dialogs/unsaved_dialog.py
index 829c658..0bd8ec5 100644
--- a/pySimBlocks/gui/dialogs/unsaved_dialog.py
+++ b/pySimBlocks/gui/dialogs/unsaved_dialog.py
@@ -50,7 +50,7 @@ def __init__(self, action_name: str, parent=None):
# --- Text ---
text_label = QLabel(
- "The project has unsaved changes."
+ "The project has unsaved changes. "
f"Do you want to save your changes before {action_name}?"
)
text_label.setWordWrap(True)
diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py
index 2b80f11..002d990 100644
--- a/pySimBlocks/gui/graphics/block_item.py
+++ b/pySimBlocks/gui/graphics/block_item.py
@@ -104,7 +104,7 @@ def refresh_ports(self):
self._layout_ports()
for item in self.port_items:
item.update_display_as()
- self.view.on_block_moved(self)
+ self.view.on_block_ports_refreshed(self)
# --------------------------------------------------------------
def toggle_orientation(self):
diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py
index 9078afd..9254d67 100644
--- a/pySimBlocks/gui/main_window.py
+++ b/pySimBlocks/gui/main_window.py
@@ -77,7 +77,7 @@ def __init__(self, project_path: Path):
self.addAction(self.save_action)
self.quit_action = QAction("Quit", self)
- self.quit_action.setShortcut(QKeySequence.Quit)
+ self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
self.quit_action.triggered.connect(self.close)
self.addAction(self.quit_action)
diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py
index a4f98bc..6102905 100644
--- a/pySimBlocks/gui/widgets/diagram_view.py
+++ b/pySimBlocks/gui/widgets/diagram_view.py
@@ -127,6 +127,12 @@ def on_block_moved(self, block_item: BlockItem):
conn_item.invalidate_manual_route()
conn_item.update_position()
+ # --------------------------------------------------------------
+ def on_block_ports_refreshed(self, block_item: BlockItem):
+ for conn_inst, conn_item in self.connections.items():
+ if conn_inst.is_block_involved(block_item.instance):
+ conn_item.update_position()
+
# --------------------------------------------------------------------------
# Event handlers
# --------------------------------------------------------------------------
@@ -162,7 +168,7 @@ def keyPressEvent(self, event):
return
# DELETE
- if event.key() == Qt.Key_Delete:
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
self.delete_selected()
return
diff --git a/pySimBlocks/project/generate_sofa_controller.py b/pySimBlocks/project/generate_sofa_controller.py
index 611998a..6b21434 100644
--- a/pySimBlocks/project/generate_sofa_controller.py
+++ b/pySimBlocks/project/generate_sofa_controller.py
@@ -70,7 +70,10 @@ def detect_controller_file_from_scene(scene_file: Path) -> Path:
parent_conn, child_conn = Pipe()
p = Process(target=_load_scene_in_subprocess, args=(scene_file, child_conn))
p.start()
- controller_path = parent_conn.recv()
+ try:
+ controller_path = parent_conn.recv()
+ except EOFError:
+ controller_path = None
p.join()
if controller_path is None:
@@ -110,8 +113,15 @@ def inject_project_path_into_controller(
src = inject_base_dir(src)
controller_dir = controller_file.parent
- rel_project = os.path.relpath(project_yaml, controller_dir)
- expr = f'self.project_yaml = str((BASE_DIR / "{rel_project}").resolve())'
+ project_yaml = project_yaml.resolve()
+
+ try:
+ rel_project = Path(os.path.relpath(project_yaml, controller_dir))
+ project_expr = f"(BASE_DIR / {rel_project.as_posix()!r}).resolve()"
+ except ValueError:
+ project_expr = f"Path({project_yaml.as_posix()!r}).resolve()"
+
+ expr = f"self.project_yaml = str({project_expr})"
pattern = r"self\.project_yaml\s*=.*"
if re.search(pattern, src):
@@ -173,7 +183,7 @@ def _resolve_scene_file(project_yaml: Path, sofa_block: dict) -> Path:
f"'scene_file' must be defined in parameters for block '{sofa_block.get('name', '?')}'"
)
- path = Path(scene_file)
+ path = Path(scene_file).expanduser()
if not path.is_absolute():
path = (project_yaml.parent / path).resolve()
return path
diff --git a/pySimBlocks/project/load_project_config.py b/pySimBlocks/project/load_project_config.py
index 840af5e..429dbf1 100644
--- a/pySimBlocks/project/load_project_config.py
+++ b/pySimBlocks/project/load_project_config.py
@@ -53,7 +53,9 @@ def _load_scope(raw: Dict[str, Any], project_yaml: Path) -> Tuple[Any, Dict[str,
if not isinstance(external_module_path, str):
raise ValueError("'simulation.external_module' must be a path to a Python file")
- external_path = project_yaml.parent / external_module_path
+ external_path = Path(external_module_path).expanduser()
+ if not external_path.is_absolute():
+ external_path = project_yaml.parent / external_path
external_module, scope = _load_external_module(external_path)
return external_module, scope
diff --git a/pySimBlocks/project/load_simulation_config.py b/pySimBlocks/project/load_simulation_config.py
index 4ba5c2e..6429559 100644
--- a/pySimBlocks/project/load_simulation_config.py
+++ b/pySimBlocks/project/load_simulation_config.py
@@ -124,7 +124,7 @@ def eval_value(value: Any, scope: dict):
- '#' is stripped (used as internal keyword)
- lists are wrapped into np.array
- eval is attempted with a restricted namespace
- - if eval fails → return original value
+ - if eval fails -> return original value
"""
try:
diff --git a/pySimBlocks/tools/generate_blocks_index.py b/pySimBlocks/tools/generate_blocks_index.py
index 7d6cea4..393b9de 100644
--- a/pySimBlocks/tools/generate_blocks_index.py
+++ b/pySimBlocks/tools/generate_blocks_index.py
@@ -99,7 +99,7 @@ def generate_blocks_index():
if not classes:
continue
- file_stem = Path(filepath).stem # snake_case name → key in YAML
+ file_stem = Path(filepath).stem # snake_case name -> key in YAML
# Compute module path
rel_path = filepath.split("pySimBlocks")[-1].lstrip("/\\")
diff --git a/tests/blocks/sources/test_chirp.py b/tests/blocks/sources/test_chirp.py
index d376155..9ff57d3 100644
--- a/tests/blocks/sources/test_chirp.py
+++ b/tests/blocks/sources/test_chirp.py
@@ -18,7 +18,7 @@ def test_chirp_scalar_at_start():
phase=0.0,
)
c.initialize(0.0)
- # tau = 0 → sin(0)=0
+ # tau = 0 -> sin(0)=0
assert np.allclose(c.outputs["out"], [[0.5]])
@@ -68,7 +68,7 @@ def test_chirp_vector_parameters():
c.output_update(0.0, 0.1)
- # tau=0 → sin(0)=0
+ # tau=0 -> sin(0)=0
assert np.allclose(c.outputs["out"], [[0.0], [10.0]])