diff --git a/.gitignore b/.gitignore index 4241acf..16893d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,13 @@ .temp/ +# CLAUDE +.claudeignore +CLAUDE.md + +# docs +docs/_build/ +docs/source/api/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..1321bd3 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +# .readthedocs.yaml +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c4f4c58 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,121 @@ +# Contributing to pySimBlocks + +Thank you for your interest in contributing to **pySimBlocks**. +pySimBlocks is a research-oriented simulation framework for discrete-time +block-diagram systems with a graphical editor and YAML project format. + +Contributions are welcome in the following areas: + +- Simulation engine +- GUI editor +- New blocks +- Documentation +- Tests +- Performance improvements + +--- + +## Repository architecture + +``` +. +├── docs/ # User guides and block documentation +├── examples/ # Usage examples +├── pySimBlocks/ +│ ├── blocks/ # Block implementations (operators, controllers, sources, …) +│ ├── core/ # Simulation engine (Model, Simulator, Block base class, …) +│ ├── gui/ # PySide6 graphical editor +│ ├── project/ # YAML project loading and code generation +│ ├── real_time/ # Real-time execution +│ └── tools/ # CLI and block registry utilities +├── tests/ # Test suite +├── pyproject.toml +└── README.md +``` + +--- + +## Docstring format + +pySimBlocks uses **Google-style docstrings**. Every public class and method must +have one. + +### Class + +```python +class MyBlock(Block): + """One-line summary ending with a period. + + Optional longer description: what the block computes, its mathematical + formulation, and any important behavioural notes. + + Attributes: + gain: Scalar gain applied to the input. + sample_time: Sampling period, or None to use the global dt. + """ +``` + +### Method + +```python +def output_update(self, t: float, dt: float) -> None: + """Compute output y[k] = gain * u[k]. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ +``` + +**Rules:** +- Always include `Args` when the method takes parameters beyond `self`. +- Include `Returns` when the return value is not `None` and not obvious. +- Include `Raises` for exceptions the caller should handle. +- Do **not** repeat type information already present in the signature. +- Private methods (`_foo`): a one-line comment is sufficient. + +--- + +## Coding style + +Follow standard Python conventions: + +- Python ≥ 3.10 +- PEP 8 formatting +- Descriptive variable names + +### Naming conventions + +| Element | Convention | Example | +|---|---|---| +| Block class | `PascalCase` | `DiscreteIntegrator`, `Gain` | +| Block file | `snake_case` | `discrete_integrator.py`, `gain.py` | +| GUI metadata class | `PascalCase` + `Meta` suffix | `DiscreteIntegratorMeta`, `GainMeta` | +| GUI metadata file | same as block file | `discrete_integrator.py` | +| Doc file | same as block file | `discrete_integrator.md` | +| Test file | `test_` + block file | `test_discrete_integrator.py` | +| Test functions | `test___` | `test_gain_negative_input_inverts_sign` | +| Block `type` key (yaml/GUI) | `snake_case` | `"discrete_integrator"` | +| Input/output port names | `snake_case` | `"in"`, `"out"`, `"error"` | +| State keys | `snake_case` | `"x"`, `"x_i"`, `"prev_e"` | + +### Type hints + +Type hints are required for all methods and functions. + +All source files must include `from __future__ import annotations` as the +first import to ensure correct rendering of type aliases (e.g. `ArrayLike`) +in the Sphinx documentation. + +--- + +## Running tests + +```bash +pytest tests/ +``` + +--- + +© 2026 Université de Lille & INRIA +Licensed under LGPL-3.0-or-later diff --git a/README.md b/README.md index e001529..92708cc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ using either: - YAML project configuration - Optional SOFA and hardware integration -![pySimBlocks graphical editor](https://raw.githubusercontent.com/AlessandriniAntoine/pySimBlocks/main/docs/User_Guide/images/gui_example.png) +![pySimBlocks graphical editor](https://raw.githubusercontent.com/AlessandriniAntoine/pySimBlocks/main/docs/source/images/user_guide/gui_example.png) ## Features @@ -103,7 +103,7 @@ plot_from_config(logs, plot_cfg) The resulting plot should look like this: -![Noise filtered](https://raw.githubusercontent.com/AlessandriniAntoine/pySimBlocks/main/docs/User_Guide/images/quick_example.png) +![Noise filtered](https://raw.githubusercontent.com/AlessandriniAntoine/pySimBlocks/main/docs/source/images/user_guide/quick_example.png) See [examples/quick_start/filter.py](./examples/quick_start/filter.py) to run the example yourself. @@ -127,13 +127,13 @@ The quick-start GUI project is stored in a single #### Tutorials Three step-by-step tutorials are available detailed in the -[guide](./docs/User_Guide/getting_started.md): +[guide](./docs/source/user_guide/tutorials.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 | + | 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 | #### Other Examples diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..13502a6 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,11 @@ +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = _build + +.PHONY: html clean + +html: + $(SPHINXBUILD) -b html $(SOURCEDIR) $(BUILDDIR)/html + +clean: + rm -rf $(BUILDDIR) diff --git a/docs/User_Guide/getting_started.md b/docs/User_Guide/getting_started.md deleted file mode 100644 index f4e85cb..0000000 --- a/docs/User_Guide/getting_started.md +++ /dev/null @@ -1,54 +0,0 @@ -# Getting Started with pySimBlocks - -pySimBlocks is a block-based simulation framework for control systems, -supporting both programmatic and graphical modeling workflows. - -This guide walks you through the core concepts in a progressive manner. -Recommended order: Tutorial 1 -> Tutorial 2 -> Tutorial 3. - -## 1. First Simulation (Python) - -Prerequisites: Python environment configured for pySimBlocks. -Level: Beginner. - -Build and simulate a closed-loop system directly in Python. - -You will learn: -- How to create blocks -- How to connect signals -- How to run a discrete-time simulation -- How to log and visualize results - --> [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) - -Prerequisites: Tutorial 1 completed, pySimBlocks GUI installed. -Level: Beginner. - -Rebuild the same system using the graphical interface. - -You will learn: -- How to create a model visually -- How to configure blocks and simulation settings -- How to save and export the project - --> [Start Tutorial 2 — GUI](tutorial_2_gui.md) -After this tutorial: You can create and manage equivalent models in the GUI. - -## 3. SOFA Coupling - -Prerequisites: Tutorials 1 and 2 completed, SOFA installed and available. -Level: Intermediate. - -Replace the plant with a SOFA model. - -You will learn: -- How to set up the environment for SOFA coupling -- How to interface pySimBlocks with SOFA -- How to run co-simulations -- How to use SOFA's GUI for inspection and debugging - --> [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/tutorial_1_python.md b/docs/User_Guide/tutorial_1_python.md deleted file mode 100644 index 98eb501..0000000 --- a/docs/User_Guide/tutorial_1_python.md +++ /dev/null @@ -1,190 +0,0 @@ -# Tutorial 1: First Steps with pySimBlocks - -## 1. Overview - -### 1.1 Goals - -This example introduces the core concepts of pySimBlocks: -- Creating blocks -- Connecting signals -- Running a discrete-time simulation -- Logging and plotting results - -By the end of this tutorial, you will be able to build and simulate your own block-based model in Python. - -### 1.2 System Description - -We build a simple closed-loop control system composed of three elements: -- a step reference, -- a PI controller, -- a first-order discrete-time linear plant. - -![Block-Diagram](./images/tutorial_1-block_diagram.png) - -**Plant — Linear State-Space System** -The plant is a discrete-time first-order linear system defined by: -$$\begin{array}{rcl} x[k+1] &=& a\,x[k] + b\,u[k] \\ y[k] &=& x[k] \end{array}$$ -The initial state is $x[0] = 0$. - -**Controller — PI** -The PI controller computes a control command from the tracking error $e[k] = r[k] - y[k]$: -$$ u[k] = K_p\, e[k] + x_i[k] $$ -where the integral state evolves as: -$$x_i[k+1] = x_i[k] + K_i\, e[k]\, dt$$ -The integral action ensures zero steady-state error on a step reference. - -## 2. Complete Example - -Below is the full working example. -You can find it in the file -[main.py](../../examples/tutorials/tutorial_1_python/main.py) in the repository. - -```python -import matplotlib.pyplot as plt -import numpy as np - -from pySimBlocks import Model, SimulationConfig, Simulator -from pySimBlocks.blocks.controllers import Pid -from pySimBlocks.blocks.operators import Sum -from pySimBlocks.blocks.sources import Step -from pySimBlocks.blocks.systems import LinearStateSpace - - -def main(): - """This example demonstrates how to use the pySimBlocks library to create a - simple closed-loop control system with a step reference input, a PI controller, - and a linear state-space system. - """ - - A = np.array([[0.9]]) - B = np.array([[0.5]]) - C = np.array([[1.0]]) - x0 = np.zeros((A.shape[0], 1)) - - Kp = np.array([[0.5]]) - Ki = np.array([[2.]]) - - # ------------------------------------------------------- - # 1. Create the blocks - # ------------------------------------------------------- - step = Step( - name="ref", - value_before=np.array([[0.0]]), - value_after=np.array([[1.0]]), - start_time=0.5 - ) - sum = Sum(name="error", signs="+-") - pid = Pid(name="PID", controller="PI", Kp=Kp, Ki=Ki) - system = LinearStateSpace(name="system", A=A, B=B, C=C, x0=x0) - - # ------------------------------------------------------- - # 2. Build the model - # ------------------------------------------------------- - model = Model("test") - for block in [step, sum, pid, system]: - model.add_block(block) - - model.connect("ref", "out", "error", "in1") - model.connect("system", "y", "error", "in2") - model.connect("error", "out", "PID", "e") - model.connect("PID", "u", "system", "u") - - # ------------------------------------------------------- - # 3. Create the simulator - # ------------------------------------------------------- - dt = 0.01 # seconds - T = 5. # seconds - sim_cfg = SimulationConfig(dt, T) - sim = Simulator(model, sim_cfg, verbose=False) - - # ------------------------------------------------------- - # 4. Run the simulation with logging - # ------------------------------------------------------- - logs = sim.run(logging=[ - "ref.outputs.out", - "PID.outputs.u", - "system.outputs.y" - ] - ) - - # ------------------------------------------------------- - # 5. Extract logged data - # ------------------------------------------------------- - t = sim.get_data("time") - u = sim.get_data("PID.outputs.u").squeeze() - r = sim.get_data("ref.outputs.out").squeeze() - y = sim.get_data("system.outputs.y").squeeze() - - # ------------------------------------------------------- - # 6. Plot the result - # ------------------------------------------------------- - fig, axs = plt.subplots(1, 2, sharex=True) - axs[0].step(t, r, "--r", label="ref", where="post") - axs[0].step(t, y, "--b", label="output", where="post") - axs[0].set_xlabel("Time [s]") - axs[0].set_ylabel("Amplitude") - axs[0].set_title("Closed-loop response") - axs[0].grid(True) - axs[0].legend() - - axs[1].step(t, u, "--b", label="u[k] (pid output)", where="post") - axs[1].set_xlabel("Time [s]") - axs[1].set_ylabel("Amplitude") - axs[1].set_title("PID controller output") - axs[1].grid(True) - axs[1].legend() - - plt.show() - -``` -## 3. How It Works - -The example follows a simple workflow: - -1. **Blocks are created** - Each component of the system (reference, controller, plant) is represented as a block. -2. **Blocks are added to a Model** - The `Model` acts as a container for all blocks. -3. **Signals are connected explicitly** - Connections define how outputs are propagated to downstream inputs, forming a directed graph. -4. **The Simulator executes the model in discrete time** - At each time step: - - Blocks compute their outputs - - Signals are propagated - - States are updated -5. **Selected signals are logged and retrieved** - Logged variables can be accessed using `sim.get_data()` and visualized with standard Python tools. - -This structure reflects the core philosophy of pySimBlocks: explicit block modeling with deterministic discrete-time execution. - -## 4. About the data shapes - -All signals in pySimBlocks follow a strict 2D convention: - -- Scalars are represented as `(1,1)` -- Vectors are `(n,1)` -- Matrices are `(m,n)` - -When logging a SISO signal over time, the resulting array has shape: `(N, 1, 1)` where `N` is the number of simulation steps. - -For plotting convenience, we use: -```python -y = sim.get_data("system.outputs.y").squeeze() -``` - -## 5. Try it yourself - -To better understand the framework, try experimenting with: - -- Changing the controller gains (`Kp`, `Ki`) -- Modifying the system dynamics (`A`, `B`) -- Adjusting the time step `dt` -- Increasing the simulation duration `T` - -Observe how the closed-loop response changes. - -This simple example is the foundation for more advanced use cases, -including: -- [GUI modeling](./tutorial_2_gui.md), -- [SOFA integration](./tutorial_3_sofa.md), - diff --git a/docs/User_Guide/tutorial_2_gui.md b/docs/User_Guide/tutorial_2_gui.md deleted file mode 100644 index 13d0dcd..0000000 --- a/docs/User_Guide/tutorial_2_gui.md +++ /dev/null @@ -1,190 +0,0 @@ -# Tutorial 2 — Building a Model with the GUI - -## 1. Overview - -We rebuild the same closed-loop system as in [Tutorial 1](./tutorial_1_python.md), but using the graphical editor. - -### 1.1 Goals - -The objective is to: -- Add blocks visually -- Configure their parameters -- Connect signals -- Run the simulation -- Export the project - -By the end of this tutorial, you will be able to build and simulate your own block-based model with the GUI. - -### 1.2 System Reminder - -We build a simple closed-loop control system composed of three elements: -- a step reference, -- a PI controller, -- a first-order discrete-time linear plant. - -![Block Diagram](./images/tutorial_1-block_diagram.png) - -## 2. Step-by-Step Construction - -### 2.1 Create a New Project - -Create a new project folder and start the GUI: -```bash -$ mkdir tutorial-gui -$ cd tutorial-gui -$ pysimblocks gui -``` -The main window opens, with the `Toolbar` at the top, the `Blocks List` on the left, and the `Diagram View` on the right. -![Gui](./images/tutorial_2-gui_main.png) - -### 2.2 Add the Blocks - -Add the required blocks to the diagram. -1. Double-click a category in the `Blocks List` to expand it. -2. Drag the selected block into the `Diagram View` -3. Repeat this for all required blocks and arrange them in the `Diagram View`. - -![Drag-Drop](./images/tutorial_2-drag_drop.gif) - -> **Notes:** -> - You can click anywhere in the `Diagram View`, and use `space` to center the view on -> all blocks. -> - To rotate a block, select it and press `Ctrl+R`. - -### 2.3 Connect the Blocks - -In a block, input ports are represented by a circle and output ports by a triangle. - -To connect two blocks: Select the first port (input or output) and drag the connection to the target port. - -When dragging the cable, a dashed line appears between the starting port and the cursor. The line becomes solid once the connection is valid. - -![Connection](./images/tutorial_2-connections.gif) - -⚠️ The two ports must be of different types (input to output). - -> **Notes:** You can move a connection by selecting the line and moving your mouse. -> However, if a connected block moves, the connection is recomputed. - -### 2.4 Configure the Parameters - -Configure the block and simulation parameters. - -#### 2.4.a For the Blocks - -Open the block dialog by double-clicking the block. - -The dialog displays all available block parameters. Some parameters are optional, while others accept only predefined values. Some parameters are populated with default values. - -Modify the following parameters: -| Block | Parameter | Value | -| --- | --- | --- | -| LinearStateSpace | A | 0.9 | -| LinearStateSpace | B | 0.5 | -| LinearStateSpace | C | 1.0 | -| LinearStateSpace | x0 | 0. | -| PID | controller | PI | -| PID | Kp | 0.5 | -| 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`. - -All required parameters must be defined before running the simulation. - -![Block-Dialog](./images/tutorial_2-block_dialog.gif) - -> **Notes:** -> - Scalars are represented as `(1,1)` arrays. But you can create matrix or vector by -> defining `[[0.5], [0.3]]` which will be set to a `(2,1)` numpy array. -> - Use the Help button for a detailed description of the block. - -#### 2.4.b For 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 - -Click the `Settings` button in the `Toolbar`. A dialog opens with multiple panels. One is for the simulation. - -Custom plots can be defined in the `Plots` panel for quick access after the simulation You must define: -- the title -- the signals -![Setting](./images/tutorial_2-setting.gif) - -⚠️ Click `Apply` before switching panels, otherwise the changes will not be saved. -Click on `ok` to close. - -### 2.5 Run the Simulation and Visualize the Results - -Once all parameters are configured, run the simulation using the `Run` button in the `Toolbar`. - -Click the `Plot` button in the `Toolbar` to visualize the results. A dialog opens where you can either: -- Plot selected logged signals in a single graph. -- Open your predefined plots. -![Plots](./images/tutorial_2-plots.gif) - -Under the hood, the GUI generates the same `Model` structure used in [Tutorial 1](./tutorial_1_python.md). The execution engine remains identical. - -## 3. Generate Project Files - -### 3.1 Saving - -Saving the project using the `Save` button in the `Toolbar` creates a unified `project.yaml` file in the current folder. - -This file contains: -- project metadata -- simulation settings (`dt`, `T`, logging, plots) -- block diagram (`diagram.blocks`, `diagram.connections`) -- GUI layout (`gui.layout`) - -This single file fully describes the model and can be reloaded in the GUI or executed programmatically in Python. - -### 3.2 Exporting a Python Runner - -The `Export` button in the `Toolbar` generates a `run.py` file. - -This script loads `project.yaml` and builds the corresponding `Model` and `Simulator` objects programmatically. -It allows the project to be executed directly from the command line: - -```bash -python run.py -``` - -The export mechanism bridges the GUI and the Python API, ensuring that a visually designed model can be reproduced and executed in a script-based workflow. - -## 4. Comparison with Python Version - -The system built in this tutorial is identical to the one created manually in [Tutorial 1](./tutorial_1_python.md). - -In the Python version, blocks and connections are defined explicitly in code: - -- Blocks are instantiated programmatically -- Connections are created using `model.connect(...)` -- The `Simulator` is constructed directly - -In the GUI version: - -- The block diagram is created visually -- The configuration is saved as YAML files -- The `Export` function generates a `run.py` script that builds the same `Model` structure - -In both cases, the execution engine is strictly the same. - -## 5. Try It Yourself - -Experiment with the model to better understand the GUI workflow: - -- Modify the controller gains (`Kp`, `Ki`) and observe the effect on the response. -- Change the system dynamics (`A`, `B`) and analyze stability. -- Adjust the sample time or simulation duration. -- Create additional custom plots in the `Plots` panel. -- Rename blocks and reorganize the diagram layout. - -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). diff --git a/docs/User_Guide/tutorial_3_sofa.md b/docs/User_Guide/tutorial_3_sofa.md deleted file mode 100644 index 27bc4fc..0000000 --- a/docs/User_Guide/tutorial_3_sofa.md +++ /dev/null @@ -1,354 +0,0 @@ -# Tutorial 3 — Coupling pySimBlocks with SOFA - -## 1. Overview - -This tutorial builds on the closed-loop system introduced in [Tutorial 1](./tutorial_1_python.md) -and [Tutorial 2](./tutorial_2_gui.md). We demonstrate how **pySimBlocks can be -coupled with SOFA** to control and interact with a physics-based simulation. - -### 1.1 Goals - -The main objectives of this tutorial are to: -- Show how to set up environment variables to couple pySimBlocks with SOFA -- Replace the linear plant from previous tutorials with a SOFA simulation -- Create a SOFA controller to receive and send signals to pySimBlocks -- Run the coupled simulation and visualize results - -### 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 - -![Block Diagram](./images/tutorial_3-block_diagram.png) - -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/). - -![SOFA Scene](./images/tutorial_3-sofa_scene.png) - -## 2. Prerequisites — Environment Setup - -Before coupling **pySimBlocks** with SOFA, you must install SOFA and configure -your environment so that: - -- Python can import the `Sofa` module -- `runSofa` can be located from your system -- pySimBlocks can launch and interact with SOFA - -### 2.1 Install SOFA - -> **Compatibility:** pySimBlocks has been tested with SOFA v24.06 and later. - -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: - -- For a binary release: the extracted folder (containing `bin/`, `plugins/`, etc.) -- For a compiled version: the build directory - - -### 2.2 Set Environment Variables - -> **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 -``` - -> **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 - -To verify that the setup is correct, open a Python terminal and try -importing the `Sofa` module: -```python -import Sofa -import SofaRuntime -``` -This should work without errors. - -Verify that `runSofa` can be launched from the command line: - -- **Linux / macOS:** `$SOFA_ROOT/bin/runSofa` -- **Windows:** `$env:SOFA_ROOT\bin\runSofa.exe` - -Ensure `SofaPython3` (and other plugins used by the scene) are available. - -## 3. Architecture - -In this section, we describe how pySimBlocks and SOFA interact at runtime. - -### 3.1 Writing a SOFA Controller - -To enable data exchange, a custom SOFA controller must be defined by subclassing -`SofaPysimBlocksController` from `pySimBlocks.blocks.systems.sofa`. - -#### 3.1.1 Interface Contract - -Your subclass **must** define the following attributes (typically in `__init__`): - -- `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) - -Your subclass **must** implement: - -- `set_inputs()` — reads values from `self.inputs` and applies them to the SOFA scene -- `get_outputs()` — reads values from the SOFA scene and writes them to `self.outputs` - -It **may** optionally implement: - -- `prepare_scene()` — called at every simulation step before the diagram starts - exchanging data. Use it to run any warm-up logic and set `self.IS_READY = True` - when the scene is ready to receive and send signals. By default, it does nothing and sets `IS_READY` to `True` immediately. -- `save()` — called at every SOFA simulation step after outputs are read. Use it to - log or export SOFA-side data independently of pySimBlocks. - -#### 3.1.2 Example — Finger Controller -```python -from pathlib import Path -import numpy as np -from pySimBlocks.blocks.systems.sofa import SofaPysimBlocksController - -BASE_DIR = Path(__file__).resolve().parent - - -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.mo = mo - self.actuator = actuator - self.tip_index = tip_index - self.dt = root.dt.value - self.verbose = False # set to True to print debug info at each step - - # Inputs & outputs dictionaries - self.inputs = { "cable": None } - self.outputs = { "tip": None, "measure": None } - - - def get_outputs(self): - tip = self.mo.position[self.tip_index].copy() - self.outputs["tip"] = np.asarray(tip).reshape(-1, 1) - self.outputs["measure"] = np.asarray(tip[1]).reshape(-1, 1) - - def set_inputs(self): - val = self.inputs["cable"] - if val is None: - raise ValueError("Input 'cable' is not set") - val = [val.item()] - self.actuator.value = val -``` - -#### 3.1.3 Integrating the Controller into the SOFA Scene - -The controller must be instantiated and returned alongside the root node inside -the createScene function of your sofa_scene.py file: - -```python -def createScene(root): - # ... (scene setup code) - controller = FingerController(root, cable.aCableActuator, finger.tetras) - root.addObject(controller) - # ... (rest of the scene setup) - return root, controller -``` - -Returning the controller is mandatory: pySimBlocks uses it to establish the -data exchange loop between the SOFA scene and the control diagram. - -### 3.2 Setting Up the SofaPlant Block in the GUI - -In the GUI, the SOFA simulation is represented by the `SofaPlant` block, -found in the Systems category of the block list. It replaces the -`LinearStateSpace` block used in the previous tutorials. -The block exposes dynamic input and output ports defined by the parameter keys -you configure — in this tutorial, one input (cable) and one output (measure). - -![Sofa-Block](./images/tutorial_3-sofa_block.gif) - -The dialog box exposes the following parameters: -| Parameter | Description | Example | -|---|---|---| -| `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. (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. - -## 4. Running the Simulation - -pySimBlocks supports two execution modes when coupled with SOFA. The choice -depends on which process drives the simulation clock. - -### 4.1 pySimBlocks as Master - -In this mode, **pySimBlocks drives the simulation loop**. SOFA runs headlessly -in a separate background process, and the `SofaPlant` block advances it by -exactly one timestep at each control iteration. - -

- -

- -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 from the GUI or command line as in -[Tutorial 2](./tutorial_2_gui.md). - -![psb-master](./images/tutorial_3-run_psb_master.gif) - -> **Note:** In this mode SOFA runs without a graphical interface. The 3D scene -> is not displayed. Use this mode for fast, automated simulation runs. - -### 4.2 SOFA as Master - -In this mode, **SOFA drives the simulation loop**. At each SOFA timestep, the -`onAnimateBeginEvent` of the controller triggers the pySimBlocks diagram, -which computes the control output and passes it back to the scene before the -physics step is resolved. - -

- -

- -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 -simulation starts. - -![sofa-master](./images/tutorial_3-run_sofa_master.gif) - -> **Notes:** -> - pySimBlocks locates the `runSofa` executable automatically using the -> `SOFA_ROOT` environment variable configured in §2.2 — no additional setup -> is required. -> - In this mode the SOFA GUI is fully active. You can observe the -> physical simulation in real time while the pySimBlocks control diagram runs -> in the background. - -## 5. Enhanced SOFA GUI — Live Sliders and Plots - -If your SOFA installation includes the GUI developed by -[Compliance Robotics](https://github.com/SofaComplianceRobotics/SofaGLFW), additional -interactive features become available when running in SOFA-as-master mode: -real-time parameter sliders and live signal plots directly inside the SOFA -window. - -### 5.1 Live Parameter Sliders - -Sliders allow you to modify block parameters interactively while the simulation -is running — without stopping or restarting SOFA. A typical use case is tuning -controller gains in real time to observe the effect on the closed-loop response. - -Sliders are declared in the `slider_params` parameter of the `SofaPlant` block. -It is a dictionary where each key is a block parameter reference of the form -`blockname.paramname`, and each value is a list `[min, max]` defining the -slider range: - -```python -{'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. - -> **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 -> block configuration. - -### 5.2 Live Signal Plots - -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. - -No additional configuration is required: define your plots in pySimBlocks as -usual, and they will appear in the SOFA window when running in SOFA-as-master -mode with the Compliance Robotics GUI. - -### 5.3 Demo - -The GIF below shows the full workflow: -- the definition of the sliders in the `SofaPlant` block parameters, -- 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. - - -![gui-enhanced](./images/tutorial_3-sofa_gui_enhanced.gif) - -> **Notes:** More details about the GUI of Compliance Robotics can be found in their -> [documentation](https://docs-support.compliance-robotics.com/docs/next/Users/SOFARobotics/GUI-user-manual/#gui-overview). - -## 6. Try It Yourself - -Experiment with the model to better understand the pySimBlocks–SOFA workflow: - -- Modify the PI gains (Kp, Ki) and observe the effect on the tracking response. -- Change the reference value and verify that the fingertip tracks the new target. -- Run the same model in both execution modes and compare the results. -- 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. diff --git a/docs/source/_ext/api_generator.py b/docs/source/_ext/api_generator.py new file mode 100644 index 0000000..3b72911 --- /dev/null +++ b/docs/source/_ext/api_generator.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SOURCE_DIR = ROOT / "docs" / "source" +API_DIR = SOURCE_DIR / "api" +PACKAGE_DIR = ROOT / "pySimBlocks" + + +SECTIONS = { + "core": { + "title": "Core", + "intro": ( + "The ``core`` package contains the simulation primitives and runtime " + "orchestration objects." + ), + "type": "modules", + "path": PACKAGE_DIR / "core", + "generated": "generated/core", + }, + "project": { + "title": "Project", + "intro": ( + "The ``project`` package groups utilities used to load project files, " + "build simulators, and generate runnable artifacts from project data." + ), + "type": "modules", + "path": PACKAGE_DIR / "project", + "generated": "generated/project", + }, + "tools": { + "title": "Tools", + "intro": ( + "The ``tools`` package contains support utilities used by the library " + "and project tooling." + ), + "type": "modules", + "path": PACKAGE_DIR / "tools", + "generated": "generated/tools", + }, + "real_time": { + "title": "Real Time", + "intro": "The ``real_time`` package provides utilities for live execution workflows.", + "type": "modules", + "path": PACKAGE_DIR / "real_time", + "generated": "generated/real_time", + }, +} + + +BLOCK_TITLES = { + "controllers": "Controllers", + "interfaces": "Interfaces", + "observers": "Observers", + "operators": "Operators", + "optimizers": "Optimizers", + "sources": "Sources", + "systems": "Systems", +} + + +GUI_SECTIONS = { + "application": { + "title": "GUI Application", + "path": PACKAGE_DIR / "gui", + "modules": [ + "pySimBlocks.gui.editor", + "pySimBlocks.gui.main_window", + "pySimBlocks.gui.project_controller", + ], + "generated": "generated/gui/application", + }, + "dialogs": { + "title": "GUI Dialogs", + "path": PACKAGE_DIR / "gui" / "dialogs", + "generated": "generated/gui/dialogs", + }, + "graphics": { + "title": "GUI Graphics", + "path": PACKAGE_DIR / "gui" / "graphics", + "generated": "generated/gui/graphics", + }, + "models": { + "title": "GUI Models", + "path": PACKAGE_DIR / "gui" / "models", + "generated": "generated/gui/models", + }, + "services": { + "title": "GUI Services", + "path": PACKAGE_DIR / "gui" / "services", + "generated": "generated/gui/services", + }, + "widgets": { + "title": "GUI Widgets", + "path": PACKAGE_DIR / "gui" / "widgets", + "generated": "generated/gui/widgets", + }, + "addons": { + "title": "GUI Add-ons", + "path": PACKAGE_DIR / "gui" / "addons", + "generated": "generated/gui/addons", + }, + "block_support": { + "title": "GUI Block Support", + "path": PACKAGE_DIR / "gui" / "blocks", + "modules": [ + "pySimBlocks.gui.blocks.block_dialog_session", + "pySimBlocks.gui.blocks.block_meta", + "pySimBlocks.gui.blocks.parameter_meta", + "pySimBlocks.gui.blocks.port_meta", + ], + "generated": "generated/gui/blocks/support", + }, +} + + +def _title(text: str, underline: str = "=") -> str: + return f"{text}\n{underline * len(text)}\n\n" + + +def _discover_modules(path: Path, package: str) -> list[str]: + modules: list[str] = [] + for py_file in sorted(path.rglob("*.py")): + if py_file.name == "__init__.py": + continue + relative = py_file.relative_to(PACKAGE_DIR).with_suffix("") + parts = relative.parts + modules.append("pySimBlocks." + ".".join(parts)) + if package.endswith(".dialogs") or package.endswith(".addons"): + return modules + return [m for m in modules if ".__pycache__" not in m] + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _generated_doc_path(toctree: str, module: str) -> str: + return f"{toctree}/{module}.rst" + + +def _module_page(module: str) -> str: + title = module + 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" + return content + + +def _list_page(title: str, intro: str | None, modules: list[str], toctree: str) -> str: + content = _title(title) + if intro: + content += intro + "\n\n" + content += ".. toctree::\n" + content += " :maxdepth: 1\n\n" + for module in modules: + generated = f"{toctree}/{module}" + content += f" {module} <{generated}>\n" + content += "\n" + return content + + +def _modules_in_dir(path: Path, package: str) -> list[str]: + return _discover_modules(path, package) + + +def generate_api(_: object | None = None) -> None: + _generate_api_index() + _generate_section_pages() + _generate_blocks_pages() + _generate_gui_pages() + + +def _generate_api_index() -> None: + content = _title("API Reference") + content += ( + "The API reference is organized by the package structure of ``pySimBlocks``. " + "Re-exported symbols from package ``__init__`` files are intentionally not " + "duplicated here; the canonical documentation lives in the subsection where " + "each object is implemented.\n\n" + ) + content += ".. toctree::\n" + content += " :maxdepth: 2\n\n" + for entry in ["blocks/index", "core", "gui/index", "project", "tools", "real_time"]: + content += f" {entry}\n" + content += "\n" + _write(API_DIR / "index.rst", content) + + +def _generate_section_pages() -> None: + for slug, config in SECTIONS.items(): + modules = _modules_in_dir(config["path"], f"pySimBlocks.{slug}") + content = _list_page( + config["title"], + config["intro"], + modules, + config["generated"], + ) + _write(API_DIR / f"{slug}.rst", content) + for module in modules: + _write(API_DIR / _generated_doc_path(config["generated"], module), _module_page(module)) + + +def _generate_blocks_pages() -> None: + blocks_dir = API_DIR / "blocks" + content = _title("Blocks") + content += ( + "The ``blocks`` package contains the reusable simulation blocks used to build " + "models. The reference is split by block family so the runtime implementations " + "remain easy to browse.\n\n" + ) + content += ".. toctree::\n :maxdepth: 1\n\n" + for category in BLOCK_TITLES: + content += f" {category}\n" + content += "\n" + _write(blocks_dir / "index.rst", content) + + for category, label in BLOCK_TITLES.items(): + modules = _discover_modules(PACKAGE_DIR / "blocks" / category, f"pySimBlocks.blocks.{category}") + content = _list_page( + f"Block {label}", + None, + modules, + f"../generated/blocks/{category}", + ) + _write(blocks_dir / f"{category}.rst", content) + for module in modules: + _write(API_DIR / _generated_doc_path(f"generated/blocks/{category}", module), _module_page(module)) + + +def _generate_gui_pages() -> None: + gui_dir = API_DIR / "gui" + content = _title("GUI") + content += ( + "The ``gui`` package contains the editor application, its support services, " + "and the GUI-side descriptions used to expose blocks in the interface.\n\n" + ) + content += ".. toctree::\n :maxdepth: 1\n\n" + for entry in ["application", "blocks/index", "dialogs", "graphics", "models", "services", "widgets", "addons"]: + content += f" {entry}\n" + content += "\n" + _write(gui_dir / "index.rst", content) + + for slug, config in GUI_SECTIONS.items(): + if slug == "block_support": + continue + modules = config.get("modules") or _discover_modules( + config["path"], f"pySimBlocks.gui.{config['path'].relative_to(PACKAGE_DIR / 'gui').as_posix().replace('/', '.')}" + ) + content = _list_page(config["title"], None, modules, f"../{config['generated']}") + _write(gui_dir / f"{slug}.rst", content) + for module in modules: + _write(API_DIR / _generated_doc_path(config["generated"], module), _module_page(module)) + + blocks_dir = gui_dir / "blocks" + content = _title("GUI Blocks") + content += ( + "The GUI block metadata is split with the same category structure as the runtime " + "``blocks`` package, so navigation stays consistent between implementation and " + "editor-facing code.\n\n" + ) + content += ".. toctree::\n :maxdepth: 1\n\n" + for entry in [*BLOCK_TITLES.keys(), "support"]: + content += f" {entry}\n" + content += "\n" + _write(blocks_dir / "index.rst", content) + + for category, label in BLOCK_TITLES.items(): + modules = _discover_modules(PACKAGE_DIR / "gui" / "blocks" / category, f"pySimBlocks.gui.blocks.{category}") + content = _list_page( + f"GUI Block {label}", + None, + modules, + f"../../generated/gui/blocks/{category}", + ) + _write(blocks_dir / f"{category}.rst", content) + for module in modules: + _write(API_DIR / _generated_doc_path(f"generated/gui/blocks/{category}", module), _module_page(module)) + + support_modules = GUI_SECTIONS["block_support"]["modules"] + content = _list_page( + "GUI Block Support", + None, + support_modules, + "../../generated/gui/blocks/support", + ) + _write(blocks_dir / "support.rst", content) + for module in support_modules: + _write(API_DIR / _generated_doc_path("generated/gui/blocks/support", module), _module_page(module)) diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 0000000..ee8222a --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,26 @@ +body { + font-size: 16px; +} + +div.body h1, +div.body h2, +div.body h3 { + color: #16324f; +} + +div.sphinxsidebar a { + color: #0f5c8a; +} + +div.related { + background: #16324f; +} + +div.related a { + color: #ffffff; +} + +.auto-nav.user-guide-page .caption, +.auto-nav.user-guide-page > ul > li:first-child { + display: none; +} diff --git a/docs/source/_templates/sidebar/navigation.html b/docs/source/_templates/sidebar/navigation.html new file mode 100644 index 0000000..eda5176 --- /dev/null +++ b/docs/source/_templates/sidebar/navigation.html @@ -0,0 +1,3 @@ + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..6ee579b --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import os +import sys +import types +import zipfile +from pathlib import Path + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, ROOT) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "_ext"))) + +if "Sofa" not in sys.modules: + sofa = types.ModuleType("Sofa") + sofa_core = types.ModuleType("Sofa.Core") + sofa_simulation = types.ModuleType("Sofa.Simulation") + sofa_imgui = types.ModuleType("Sofa.ImGui") + + class _SofaController: + def __init__(self, *args, **kwargs): + pass + + class _SofaNode: + def __init__(self, *args, **kwargs): + pass + + def _noop(*args, **kwargs): + return None + + sofa_core.Controller = _SofaController + sofa_core.Node = _SofaNode + sofa_simulation.initRoot = _noop + sofa_simulation.animate = _noop + + sofa.Core = sofa_core + sofa.Simulation = sofa_simulation + sofa.ImGui = sofa_imgui + + sys.modules["Sofa"] = sofa + sys.modules["Sofa.Core"] = sofa_core + sys.modules["Sofa.Simulation"] = sofa_simulation + sys.modules["Sofa.ImGui"] = sofa_imgui + +project = "pySimBlocks" +author = "Antoine Alessandrini" +copyright = "2026, Universite de Lille & INRIA" +release = "0.1.1" + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} +myst_enable_extensions = [ + "dollarmath", + "colon_fence", +] + +autosummary_generate = True +autosummary_imported_members = False +autosummary_ignore_module_all = False + +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, +} + +autodoc_member_order = "bysource" +autodoc_mock_imports = [] +autodoc_type_aliases = { + "ArrayLike": "ArrayLike", +} + +html_theme = "furo" +html_title = "pySimBlocks Documentation" +html_static_path = ["_static"] +html_css_files = ["custom.css"] + + +def generate_tutorial_archives(app): + source_dir = Path(__file__).resolve().parent + tutorials_root = Path(ROOT) / "examples" / "tutorials" + archive_dir = source_dir / "_static" / "downloads" + archive_dir.mkdir(parents=True, exist_ok=True) + + tutorial_dir = tutorials_root / "tutorial_3_sofa" + archive_path = archive_dir / "tutorial_3_sofa.zip" + + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in sorted(tutorial_dir.rglob("*")): + if not file_path.is_file(): + continue + if "__pycache__" in file_path.parts or file_path.suffix == ".pyc": + continue + archive_name = Path("tutorial_3_sofa") / file_path.relative_to(tutorial_dir) + archive.write(file_path, archive_name.as_posix()) + + +def setup(app): + from api_generator import generate_api + + app.connect("builder-inited", generate_api) + app.connect("builder-inited", generate_tutorial_archives) diff --git a/docs/User_Guide/images/gui_example.png b/docs/source/images/user_guide/gui_example.png similarity index 100% rename from docs/User_Guide/images/gui_example.png rename to docs/source/images/user_guide/gui_example.png diff --git a/docs/User_Guide/images/quick_example.png b/docs/source/images/user_guide/quick_example.png similarity index 100% rename from docs/User_Guide/images/quick_example.png rename to docs/source/images/user_guide/quick_example.png diff --git a/docs/User_Guide/images/tutorial_1-block_diagram.png b/docs/source/images/user_guide/tutorial_1-block_diagram.png similarity index 100% rename from docs/User_Guide/images/tutorial_1-block_diagram.png rename to docs/source/images/user_guide/tutorial_1-block_diagram.png diff --git a/docs/User_Guide/images/tutorial_2-block_dialog.gif b/docs/source/images/user_guide/tutorial_2-block_dialog.gif similarity index 100% rename from docs/User_Guide/images/tutorial_2-block_dialog.gif rename to docs/source/images/user_guide/tutorial_2-block_dialog.gif diff --git a/docs/User_Guide/images/tutorial_2-connections.gif b/docs/source/images/user_guide/tutorial_2-connections.gif similarity index 100% rename from docs/User_Guide/images/tutorial_2-connections.gif rename to docs/source/images/user_guide/tutorial_2-connections.gif diff --git a/docs/User_Guide/images/tutorial_2-drag_drop.gif b/docs/source/images/user_guide/tutorial_2-drag_drop.gif similarity index 100% rename from docs/User_Guide/images/tutorial_2-drag_drop.gif rename to docs/source/images/user_guide/tutorial_2-drag_drop.gif diff --git a/docs/User_Guide/images/tutorial_2-gui_main.png b/docs/source/images/user_guide/tutorial_2-gui_main.png similarity index 100% rename from docs/User_Guide/images/tutorial_2-gui_main.png rename to docs/source/images/user_guide/tutorial_2-gui_main.png diff --git a/docs/User_Guide/images/tutorial_2-plots.gif b/docs/source/images/user_guide/tutorial_2-plots.gif similarity index 100% rename from docs/User_Guide/images/tutorial_2-plots.gif rename to docs/source/images/user_guide/tutorial_2-plots.gif diff --git a/docs/User_Guide/images/tutorial_2-setting.gif b/docs/source/images/user_guide/tutorial_2-setting.gif similarity index 100% rename from docs/User_Guide/images/tutorial_2-setting.gif rename to docs/source/images/user_guide/tutorial_2-setting.gif diff --git a/docs/User_Guide/images/tutorial_3-block_diagram.png b/docs/source/images/user_guide/tutorial_3-block_diagram.png similarity index 100% rename from docs/User_Guide/images/tutorial_3-block_diagram.png rename to docs/source/images/user_guide/tutorial_3-block_diagram.png diff --git a/docs/User_Guide/images/tutorial_3-loop_psb_master.png b/docs/source/images/user_guide/tutorial_3-loop_psb_master.png similarity index 100% rename from docs/User_Guide/images/tutorial_3-loop_psb_master.png rename to docs/source/images/user_guide/tutorial_3-loop_psb_master.png diff --git a/docs/User_Guide/images/tutorial_3-loop_sofa_master.png b/docs/source/images/user_guide/tutorial_3-loop_sofa_master.png similarity index 100% rename from docs/User_Guide/images/tutorial_3-loop_sofa_master.png rename to docs/source/images/user_guide/tutorial_3-loop_sofa_master.png diff --git a/docs/User_Guide/images/tutorial_3-run_psb_master.gif b/docs/source/images/user_guide/tutorial_3-run_psb_master.gif similarity index 100% rename from docs/User_Guide/images/tutorial_3-run_psb_master.gif rename to docs/source/images/user_guide/tutorial_3-run_psb_master.gif diff --git a/docs/User_Guide/images/tutorial_3-run_sofa_master.gif b/docs/source/images/user_guide/tutorial_3-run_sofa_master.gif similarity index 100% rename from docs/User_Guide/images/tutorial_3-run_sofa_master.gif rename to docs/source/images/user_guide/tutorial_3-run_sofa_master.gif diff --git a/docs/User_Guide/images/tutorial_3-sofa_block.gif b/docs/source/images/user_guide/tutorial_3-sofa_block.gif similarity index 100% rename from docs/User_Guide/images/tutorial_3-sofa_block.gif rename to docs/source/images/user_guide/tutorial_3-sofa_block.gif diff --git a/docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif b/docs/source/images/user_guide/tutorial_3-sofa_gui_enhanced.gif similarity index 100% rename from docs/User_Guide/images/tutorial_3-sofa_gui_enhanced.gif rename to docs/source/images/user_guide/tutorial_3-sofa_gui_enhanced.gif diff --git a/docs/User_Guide/images/tutorial_3-sofa_scene.png b/docs/source/images/user_guide/tutorial_3-sofa_scene.png similarity index 100% rename from docs/User_Guide/images/tutorial_3-sofa_scene.png rename to docs/source/images/user_guide/tutorial_3-sofa_scene.png diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..6dcc4e1 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,58 @@ +pySimBlocks Documentation +========================= + +.. raw:: html + +
+ GitHub +  |  + PyPI +
+ +pySimBlocks is a block-diagram simulation framework for control-oriented +workflows. You can build models directly in Python or assemble them visually in +the graphical editor, then run the same discrete-time simulation engine in both +cases. + +.. image:: images/user_guide/tutorial_2-setting.gif + :alt: pySimBlocks graphical editor + :align: center + :width: 85% + +The documentation is organized to help different kinds of readers get to the +right page quickly. + +Key Features +------------ + +- Block-diagram modeling in Python with explicit signal connections +- Graphical editor for building and configuring models visually +- Shared discrete-time execution engine across Python and GUI workflows +- YAML project files and exportable Python runners +- Logging, plotting, and project-based simulation workflows +- Optional SOFA integration for coupled simulation + +Where Should I Start? +--------------------- + +- If you want to install pySimBlocks and run a first example, start with + :doc:`user_guide/installation` and :doc:`user_guide/quick_start`. +- If you want a guided learning path, continue with + :doc:`user_guide/tutorials/index`. +- If you want to work primarily from Python code, the tutorials begin with a + pure Python example before moving to the GUI. +- If you want the reference for modules, classes, and functions, go to + :doc:`api/index`. + +Documentation Overview +---------------------- + +- The User Guide covers installation, a quick start, and progressive tutorials. +- The API Reference documents the Python package structure and public objects. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + user_guide/index + api/index diff --git a/docs/source/user_guide/cli.md b/docs/source/user_guide/cli.md new file mode 100644 index 0000000..4cf54a5 --- /dev/null +++ b/docs/source/user_guide/cli.md @@ -0,0 +1,83 @@ +# Command Line Interface + +`pySimBlocks` provides a small command line interface through the +`pysimblocks` command. + +To display the available commands, run: + +```bash +pysimblocks --help +``` + +## Launch the GUI + +Use the `gui` command to open the graphical editor. + +Open the current directory as a project: + +```bash +pysimblocks gui +``` + +Open a specific project directory: + +```bash +pysimblocks gui path/to/project_dir +``` + +If the GUI does not start, make sure `PySide6` is installed. + +## Export a Python Runner + +Use the `export` command to generate a `run.py` script from a `project.yaml` +file. + +Export from a project directory: + +```bash +pysimblocks export --directory path/to/project_dir +``` + +Export from a specific project file: + +```bash +pysimblocks export --file path/to/project.yaml +``` + +Choose an explicit output path: + +```bash +pysimblocks export --file path/to/project.yaml --out path/to/run.py +``` + +This generated script rebuilds the model and runs the simulation from the +command line. + +## Export a SOFA Controller + +If your project uses SOFA integration, you can update the SOFA controller +generated from `project.yaml`: + +```bash +pysimblocks export --directory path/to/project_dir --sofa-controller +``` + +You can also target a specific project file: + +```bash +pysimblocks export --file path/to/project.yaml --sofa-controller +``` + +If this command fails, check your SOFA integration setup and the associated +project files. + +## Update the Block Index + +The `update` command regenerates the internal pySimBlocks block index: + +```bash +pysimblocks update +``` + +This command is mainly useful when developing pySimBlocks itself or updating +the available block registry. diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst new file mode 100644 index 0000000..ce528fd --- /dev/null +++ b/docs/source/user_guide/index.rst @@ -0,0 +1,16 @@ +User Guide +========== + +This section is intended for practical usage documentation of ``pySimBlocks``. + +It currently starts with installation and a short quick start, then continues +with a progressive tutorial path. + +.. toctree:: + :maxdepth: 2 + :includehidden: + + installation + quick_start + cli + tutorials/index diff --git a/docs/source/user_guide/installation.md b/docs/source/user_guide/installation.md new file mode 100644 index 0000000..042fd72 --- /dev/null +++ b/docs/source/user_guide/installation.md @@ -0,0 +1,49 @@ +# Installation + +`pySimBlocks` requires Python 3.10 or newer. + +## From PyPI + +```bash +pip install pySimBlocks +``` + +## From GitHub + +```bash +pip install git+https://github.com/AlessandriniAntoine/pySimBlocks +``` + +## Local Development Install + +```bash +git clone https://github.com/AlessandriniAntoine/pySimBlocks.git +cd pySimBlocks +pip install . +``` + +## Documentation build + +To build the documentation locally from the `docs` directory: + +```bash +pip install pySimBlocks[docs] +make html +``` + +## Optional dependencies + +### Examples + +Some examples require additional dependencies. You can install them with: + +```bash +pip install pySimBlocks[examples] +``` + +### Testing +To run the tests, you need to install the testing dependencies: + +```bash +pip install pySimBlocks[tests] +``` diff --git a/docs/source/user_guide/quick_start.md b/docs/source/user_guide/quick_start.md new file mode 100644 index 0000000..031fe32 --- /dev/null +++ b/docs/source/user_guide/quick_start.md @@ -0,0 +1,41 @@ +# Quick Start + +This quick start shows how to build and run a simple first-order low-pass +filter with `pySimBlocks`, first with the Python API and then with the +graphical editor. + +## Python API example + +The following example models a simple first-order low-pass filter defined by: + +$$ +y[k] = \alpha x[k] + (1-\alpha) y[k-1] +$$ + +```{literalinclude} ../../../examples/quick_start/filter.py +:language: python +:caption: examples/quick_start/filter.py +``` + +The resulting plot should look like this: + +![Noise filtered](../images/user_guide/quick_example.png) + +## Graphical editor + +The same model can also be created visually with the graphical editor. + +![pySimBlocks graphical editor](../images/user_guide/gui_example.png) + +To open the graphical editor with the quick-start project: + +```bash +pysimblocks gui examples/quick_start/gui +``` + +## Downloads + +You can download the quick-start example files here: + +- {download}`filter.py <../../../examples/quick_start/filter.py>` +- {download}`project.yaml <../../../examples/quick_start/gui/project.yaml>` diff --git a/docs/source/user_guide/tutorials/index.md b/docs/source/user_guide/tutorials/index.md new file mode 100644 index 0000000..7da7690 --- /dev/null +++ b/docs/source/user_guide/tutorials/index.md @@ -0,0 +1,68 @@ +# Tutorials + +This section provides a progressive learning path for `pySimBlocks`. + +The tutorials are meant to be read in order. Each one builds on the previous +one and introduces a new way of working with the library. + +```{toctree} +:maxdepth: 1 + +tutorial_1_python +tutorial_2_gui +tutorial_3_sofa +``` + +## Tutorial 1: First Simulation in Python + +Prerequisites: a working `pySimBlocks` Python installation. +Level: Beginner. + +Build and simulate a simple closed-loop system directly in Python. + +You will learn: + +- How to create blocks +- How to connect signals +- How to run a discrete-time simulation +- How to log and visualize results + +After this tutorial, you will be able to assemble and simulate a basic model +from code. + +## Tutorial 2: Build the Same Model with the GUI + +Prerequisites: Tutorial 1 completed and the graphical editor available. +Level: Beginner. + +Rebuild the same closed-loop system visually with the `pySimBlocks` GUI. + +You will learn: + +- How to add blocks in the editor +- How to configure block and simulation parameters +- How to connect signals visually +- How to save and export a GUI project + +After this tutorial, you will be able to create the same model either from +code or from the graphical editor. + +## Tutorial 3: Couple the Model with SOFA + +Prerequisites: Tutorial 2 completed, SOFA installed, and the SOFA Python +bindings configured. +Level: Intermediate. + +Replace the simple linear plant with a SOFA simulation and run the closed-loop +diagram in co-simulation. + +You will learn: + +- How to configure `SOFA_ROOT` and the SOFA Python bindings +- How to define a `SofaPysimBlocksController` +- How to configure a `SofaPlant` block in the GUI +- How to run the model with either `pySimBlocks` or SOFA as the master process + +After this tutorial, you will be able to connect a `pySimBlocks` controller to +a SOFA scene and run the coupled system from the GUI or the published example +files. diff --git a/docs/source/user_guide/tutorials/tutorial_1_python.md b/docs/source/user_guide/tutorials/tutorial_1_python.md new file mode 100644 index 0000000..9868cef --- /dev/null +++ b/docs/source/user_guide/tutorials/tutorial_1_python.md @@ -0,0 +1,109 @@ +# Tutorial 1: First Steps with pySimBlocks + +## Overview + +This tutorial introduces the core concepts of `pySimBlocks`: + +- Creating blocks +- Connecting signals +- Running a discrete-time simulation +- Logging and plotting results + +By the end of this tutorial, you will be able to build and simulate your own +block-based model in Python. + +## Example Files + +The full example lives in `examples/tutorials/tutorial_1_python/`. +The main file is: + +- [`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>` + +## System Description + +We build a simple closed-loop control system composed of three elements: + +- A step reference +- A PI controller +- A first-order discrete-time linear plant + +![Block diagram](../../images/user_guide/tutorial_1-block_diagram.png) + +The plant is a discrete-time first-order linear system defined by: + +$$ +\begin{array}{rcl} +x[k+1] &=& a\,x[k] + b\,u[k] \\ +y[k] &=& x[k] +\end{array} +$$ + +The initial state is $x[0] = 0$. + +The PI controller computes a control command from the tracking error +$e[k] = r[k] - y[k]$: + +$$ +u[k] = K_p\, e[k] + x_i[k] +$$ + +with the integral state evolving as: + +$$ +x_i[k+1] = x_i[k] + K_i\, e[k]\, dt +$$ + +This integral action removes steady-state error for a step reference. + +## Complete Example + +```{literalinclude} ../../../../examples/tutorials/tutorial_1_python/main.py +:language: python +:caption: examples/tutorials/tutorial_1_python/main.py +``` + +## How It Works + +The example follows a simple workflow: + +1. Blocks are created. +2. Blocks are added to a `Model`. +3. Signals are connected explicitly. +4. A `Simulator` executes the model in discrete time. +5. Selected signals are logged and retrieved for plotting. + +This reflects the core philosophy of `pySimBlocks`: explicit block modeling +with deterministic discrete-time execution. + +## About Signal Shapes + +All signals in `pySimBlocks` follow a strict 2D convention: + +- Scalars are represented as `(1, 1)` +- Vectors are `(n, 1)` +- Matrices are `(m, n)` + +When logging a SISO signal over time, the resulting array has shape +`(N, 1, 1)`, where `N` is the number of simulation steps. + +For plotting convenience, the example uses: + +```python +y = sim.get_data("system.outputs.y").squeeze() +``` + +## Try It Yourself + +To explore the framework further, try: + +- Changing the controller gains `Kp` and `Ki` +- Modifying the system dynamics `A` and `B` +- Adjusting the time step `dt` +- Increasing the simulation duration `T` + +Observe how the closed-loop response changes. diff --git a/docs/source/user_guide/tutorials/tutorial_2_gui.md b/docs/source/user_guide/tutorials/tutorial_2_gui.md new file mode 100644 index 0000000..efb2728 --- /dev/null +++ b/docs/source/user_guide/tutorials/tutorial_2_gui.md @@ -0,0 +1,208 @@ +# Tutorial 2: Building a Model with the GUI + +In this tutorial, we rebuild the same closed-loop system as in +[Tutorial 1](./tutorial_1_python.md), but using the graphical editor. + +## Goals + +The objective is to: + +- Add blocks visually +- Configure their parameters +- Connect signals +- Run the simulation +- Save and export the project + +By the end of this tutorial, you will be able to build and simulate your own +block-based model with the GUI. + +## System Reminder + +We build a simple closed-loop control system composed of three elements: + +- A step reference +- A PI controller +- A first-order discrete-time linear plant + +![Block diagram](../../images/user_guide/tutorial_1-block_diagram.png) + +## Step-by-Step Construction + +### Create a New Project + +Create a new project folder and start the GUI: + +```bash +mkdir tutorial-gui +cd tutorial-gui +pysimblocks gui +``` + +The main window opens, with the toolbar at the top, the block list on the +left, and the diagram view on the right. + +![GUI main window](../../images/user_guide/tutorial_2-gui_main.png) + +### Add the Blocks + +Add the required blocks to the diagram: + +1. Double-click a category in the block list to expand it. +2. Drag the selected block into the diagram view. +3. Repeat this for all required blocks and arrange them on the canvas. + +![Drag and drop blocks](../../images/user_guide/tutorial_2-drag_drop.gif) + +Notes: + +- Press `Space` in the diagram view to center the view on all blocks. +- Select a block and press `Ctrl+R` to rotate it. + +### Connect the Blocks + +Input ports are represented by circles and output ports by triangles. + +To create a connection, select a port and drag the connection to the target +port. While dragging, a dashed line appears. The line becomes solid once the +connection is valid. + +![Create connections](../../images/user_guide/tutorial_2-connections.gif) + +The ports must be of different types: output to input. + +### Configure the Parameters + +Configure both the blocks and the simulation settings. + +#### Block Parameters + +Open a block dialog by double-clicking the block. + +Set the following parameters: + +| Block | Parameter | Value | +| --- | --- | --- | +| LinearStateSpace | A | 0.9 | +| LinearStateSpace | B | 0.5 | +| LinearStateSpace | C | 1.0 | +| LinearStateSpace | x0 | 0. | +| PID | controller | PI | +| PID | Kp | 0.5 | +| PID | Ki | 2. | +| Sum | signs | +- | + +If you rename the blocks, use the same names as in +[Tutorial 1](./tutorial_1_python.md): `ref`, `error`, `PID`, and `system`. + +![Block dialog](../../images/user_guide/tutorial_2-block_dialog.gif) + +Notes: + +- Scalars are represented as `(1,1)` arrays. +- You can define vectors or matrices with Python-like nested lists such as + `[[0.5], [0.3]]`. +- Use the Help button for a description of the selected block. + +#### Simulation Settings + +Open the simulation settings from the toolbar and configure: + +- Sample time: `0.01` s +- Duration: `5` s +- Signals to log: `system.outputs.y`, `PID.outputs.u`, and `ref.outputs.out` + +You can also define custom plots in the plots panel for quick access after the +simulation. + +![Simulation settings](../../images/user_guide/tutorial_2-setting.gif) + +Click `Apply` before switching panels, otherwise the changes are not saved. + +### Run the Simulation and Visualize the Results + +Once all parameters are configured, run the simulation with the toolbar run +button. + +Then open the plot dialog to either: + +- Plot selected logged signals in a single graph +- Open one of your predefined plots + +![Plot dialog](../../images/user_guide/tutorial_2-plots.gif) + +Under the hood, the GUI generates the same `Model` structure used in +[Tutorial 1](./tutorial_1_python.md). The execution engine is the same. + +## Save and Export Project Files + +### Save the Project + +Saving from the toolbar creates a `project.yaml` file in the current folder. + +This file contains: + +- Project metadata +- Simulation settings +- Block diagram data +- GUI layout information + +This single file fully describes the model and can be reloaded in the GUI or +executed programmatically. + +### Export a Python Runner + +The `Export` button generates a `run.py` file. + +This script loads `project.yaml`, rebuilds the corresponding `Model`, and runs +the simulation from the command line: + +```bash +python run.py +``` + +## Reference Project + +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: + +- [`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>` + +## Comparison with the Python Version + +The system built in this tutorial is identical to the one created manually in +[Tutorial 1](./tutorial_1_python.md). + +In the Python version: + +- Blocks are instantiated in code +- Connections are created with `model.connect(...)` +- The simulator is built directly + +In the GUI version: + +- The diagram is created visually +- The configuration is stored as YAML +- Export generates a Python runner from that configuration + +In both cases, the execution engine is the same. + +## Try It Yourself + +Experiment with the model to better understand the GUI workflow: + +- Modify the controller gains `Kp` and `Ki` +- Change the system dynamics `A` and `B` +- Adjust the sample time or simulation duration +- Create additional custom plots +- Rename blocks and reorganize the layout + +After modifying the model, save the project and export a new `run.py` file to +verify that the exported script reproduces the same behavior. diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md new file mode 100644 index 0000000..fd96c1d --- /dev/null +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -0,0 +1,187 @@ +# Tutorial 3: Coupling pySimBlocks with SOFA + +This tutorial builds on [Tutorial 1](./tutorial_1_python.md) and +[Tutorial 2](./tutorial_2_gui.md). The linear plant is replaced with a SOFA +scene so the controller interacts with a physics-based simulation. + +## Goals + +The objective is to: + +- Configure the environment needed to run SOFA from `pySimBlocks` +- Replace the plant with a `SofaPlant` block +- Create a SOFA controller that exchanges signals with the block diagram +- Run the coupled model in both execution modes + +By the end of this tutorial, you will be able to connect a `pySimBlocks` +control loop to a SOFA simulation. + +## Required Files + +The full example lives in `examples/tutorials/tutorial_3_sofa/`. +The main files are: + +- [`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. + +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: + +{download}`Download tutorial_3_sofa.zip <../../_static/downloads/tutorial_3_sofa.zip>` + +## System Description + +We build the same closed-loop structure as in the previous tutorials: + +- A constant reference +- A PI controller +- A SOFA simulation of a tendon-driven finger + +![Block diagram](../../images/user_guide/tutorial_3-block_diagram.png) + +The control input is the cable actuation, and the measured output is the +vertical fingertip position. + +![SOFA scene](../../images/user_guide/tutorial_3-sofa_scene.png) + +## Prerequisites + +Before coupling `pySimBlocks` with SOFA, make sure: + +- SOFA is installed +- Python can import `Sofa` +- `runSofa` is available through `SOFA_ROOT` + +`pySimBlocks` has been tested with SOFA `v24.06` and later. + +### Linux and macOS + +```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 +``` + +### Windows + +```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" +``` + +### Verify the Setup + +Check that Python can import SOFA: + +```python +import Sofa +import SofaRuntime +``` + +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 +`SofaPysimBlocksController` from `pySimBlocks.blocks.systems.sofa`. + +Your controller must define: + +- `self.project_yaml` +- `self.dt` +- `self.inputs` +- `self.outputs` + +It must implement: + +- `set_inputs()` +- `get_outputs()` + +### Example Controller + +The tutorial example uses the following controller: +```{literalinclude} ../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py +:language: python +:caption: examples/tutorials/tutorial_3_sofa/finger/FingerController.py +``` + +The scene must instantiate that controller and return it from `createScene(...)` +alongside the root node. + +## Configure the SofaPlant Block + +In the GUI, replace the `LinearStateSpace` block from the previous tutorials +with a `SofaPlant` block from the Systems category. + +![SofaPlant block](../../images/user_guide/tutorial_3-sofa_block.gif) + +Set the block parameters as follows: + +| Parameter | Value | +| --- | --- | +| `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]}` | + +The key names must match the dictionaries declared in the SOFA controller. + +## Execution Modes + +### pySimBlocks as Master + +In this mode, `pySimBlocks` drives the clock. The `SofaPlant` block runs SOFA +headlessly and advances it by one step at each control iteration. + +![pySimBlocks master loop](../../images/user_guide/tutorial_3-loop_psb_master.png) + +Run the model as you would in [Tutorial 2](./tutorial_2_gui.md), either from +the GUI or from the exported Python runner. + +![pySimBlocks master run](../../images/user_guide/tutorial_3-run_psb_master.gif) + +### SOFA as Master + +In this mode, SOFA drives the clock and the controller triggers the +`pySimBlocks` diagram during the SOFA animation loop. + +![SOFA master loop](../../images/user_guide/tutorial_3-loop_sofa_master.png) + +Open the SOFA panel from the toolbar and click `runSofa` to launch the scene +with the SOFA GUI. + +![SOFA master run](../../images/user_guide/tutorial_3-run_sofa_master.gif) + +## Live Sliders and Plots + +If your SOFA installation includes the Compliance Robotics GUI, the parameters +declared in `slider_params` become live sliders in SOFA, and the plots defined +in the `pySimBlocks` project are displayed live in the same window. + +![Enhanced SOFA GUI](../../images/user_guide/tutorial_3-sofa_gui_enhanced.gif) + +This makes it possible to tune `ref.value`, `PID.Kp`, and `PID.Ki` while the +simulation is running. + +## Try It Yourself + +Experiment with the coupled model to better understand the workflow: + +- Modify `Kp` and `Ki` and compare the tracking behavior +- 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 diff --git a/examples/tutorials/tutorial_2_gui/project.yaml b/examples/tutorials/tutorial_2_gui/project.yaml new file mode 100644 index 0000000..188c5e1 --- /dev/null +++ b/examples/tutorials/tutorial_2_gui/project.yaml @@ -0,0 +1,95 @@ +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 + solver: fixed + logging: + - system.outputs.y + - ref.outputs.out + - PID.outputs.u + plots: + - title: Ref vs Output + signals: + - system.outputs.y + - ref.outputs.out + - title: Command + signals: + - PID.outputs.u +diagram: + blocks: + - name: system + category: systems + type: linear_state_space + parameters: + A: [[0.9]] + B: [[0.5]] + C: [[1.0]] + x0: 0.0 + - name: PID + category: controllers + type: pid + parameters: + controller: PI + Kp: 0.5 + Ki: 2.0 + - name: error + category: operators + type: sum + parameters: + signs: +- + - name: ref + category: sources + type: step + parameters: + value_before: [[0.0]] + value_after: [[1.0]] + start_time: 1.0 + connections: + - name: c1 + ports: + - ref.out + - error.in1 + - name: c2 + ports: + - error.out + - PID.e + - name: c3 + ports: + - PID.u + - system.u + - name: c4 + ports: + - system.y + - error.in2 +gui: + layout: + blocks: + system: + x: -15.0 + y: -600.0 + orientation: normal + width: 120.0 + height: 60.0 + PID: + x: -220.0 + y: -600.0 + orientation: normal + width: 120.0 + height: 60.0 + error: + x: -415.0 + y: -600.0 + orientation: normal + width: 120.0 + height: 60.0 + ref: + x: -595.0 + y: -610.0 + orientation: normal + width: 120.0 + height: 60.0 diff --git a/examples/tutorials/tutorial_2_gui/run.py b/examples/tutorials/tutorial_2_gui/run.py new file mode 100644 index 0000000..8d74233 --- /dev/null +++ b/examples/tutorials/tutorial_2_gui/run.py @@ -0,0 +1,14 @@ +from pathlib import Path +from pySimBlocks.project import load_simulator_from_project +from pySimBlocks.project.plot_from_config import plot_from_config + +try: + BASE_DIR = Path(__file__).parent.resolve() +except Exception: + BASE_DIR = Path("") + +sim, plot_cfg = load_simulator_from_project(BASE_DIR / 'project.yaml') + +logs = sim.run() +if True and plot_cfg is not None: + plot_from_config(logs, plot_cfg) diff --git a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py index f5078c9..d7e06c0 100644 --- a/examples/tutorials/tutorial_3_sofa/finger/FingerController.py +++ b/examples/tutorials/tutorial_3_sofa/finger/FingerController.py @@ -33,5 +33,4 @@ def set_inputs(self): val = self.inputs["cable"] if val is None: raise ValueError("Input 'cable' is not set") - val = [val.item()] - self.actuator.value = val + self.actuator.value = [val.item()] diff --git a/pySimBlocks/blocks/controllers/pid.py b/pySimBlocks/blocks/controllers/pid.py index eca6d98..38ee1cc 100644 --- a/pySimBlocks/blocks/controllers/pid.py +++ b/pySimBlocks/blocks/controllers/pid.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import warnings import numpy as np @@ -27,43 +29,26 @@ class Pid(Block): - """ - Discrete-time PID controller block. - - Summary: - Implements a single-input single-output discrete PID controller, - similar to the Simulink PID block. The controller computes a control - command from an error signal using proportional, integral and/or - derivative actions, depending on the selected control mode. - - Parameters (overview): - controller : str - Control mode. One of {"P", "PI", "PD", "PID"}. - Kp : float - Proportional gain. - Ki : float - Integral gain. - Kd : float - Derivative gain. - u_min : float, optional - Minimum output saturation. - u_max : float, optional - Maximum output saturation. - sample_time : float, optional - Controller sampling period. - - I/O: - Inputs: - e : error signal. - Outputs: - u : control command. - - Notes: - - The block is strictly SISO. - - Integral action introduces internal state. - - If a parameter is not provided, the block falls back to its - internal default value. - - Output saturation is applied only if u_min and/or u_max are defined. + """Discrete-time PID controller block. + + Implements a single-input single-output discrete PID controller, + similar to the Simulink PID block. The controller computes a control + command from an error signal ``e`` using proportional, integral, and/or + derivative actions depending on the selected control mode. + + Output saturation is applied only if ``u_min`` and/or ``u_max`` are set. + Anti-windup clamps the integrator state to the saturation bounds. + + Attributes: + controller: Active control mode (``"P"``, ``"I"``, ``"PI"``, + ``"PD"``, or ``"PID"``). + integration_method: Integration scheme for the I term + (``"euler forward"`` or ``"euler backward"``). + Kp: Proportional gain as a (1,1) array. + Ki: Integral gain as a (1,1) array. + Kd: Derivative gain as a (1,1) array. + u_min: Lower saturation bound as a (1,1) array, or None. + u_max: Upper saturation bound as a (1,1) array, or None. """ def __init__( @@ -78,6 +63,27 @@ def __init__( integration_method: str = "euler forward", sample_time: float | None = None, ): + """Initialize a PID controller block. + + Args: + name: Unique identifier for this block instance. + controller: Control mode. Must be one of ``{"P", "I", "PI", + "PD", "PID"}``. + Kp: Proportional gain. Must be scalar-like. + Ki: Integral gain. Must be scalar-like. + Kd: Derivative gain. Must be scalar-like. + u_min: Minimum output saturation bound. None to disable. + u_max: Maximum output saturation bound. None to disable. + integration_method: Integration scheme for the I term. + Must be ``"euler forward"`` or ``"euler backward"``. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If ``controller`` or ``integration_method`` is + invalid, if any gain is not scalar-like, or if + ``u_min > u_max``. + """ super().__init__(name, sample_time) controller = controller.upper() @@ -95,7 +101,6 @@ def __init__( f"[{self.name}] Unsupported method '{self.integration_method}'. Allowed: {allowed}" ) - # Gains (SISO, (1,1)) self.Kp = self._to_siso("Kp", Kp) self.Ki = self._to_siso("Ki", Ki) self.Kd = self._to_siso("Kd", Kd) @@ -109,13 +114,10 @@ def __init__( f"[{self.name}] u_min ({self.u_min.item()}) must be <= u_max ({self.u_max.item()})." ) - # Warnings self._validate_gains() - # Direct feedthrough policy has_p = "P" in self.controller has_d = "D" in self.controller - has_i = "I" in self.controller if has_p or has_d: self.direct_feedthrough = True @@ -123,11 +125,9 @@ def __init__( # I-only self.direct_feedthrough = (self.integration_method == "euler backward") - # Ports self.inputs["e"] = None self.outputs["u"] = None - # State (SISO) self.state["x_i"] = np.zeros((1, 1), dtype=float) self.state["e_prev"] = np.zeros((1, 1), dtype=float) self.next_state["x_i"] = np.zeros((1, 1), dtype=float) @@ -137,12 +137,25 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): - # Start at zero command, keep internal states at zero. + + def initialize(self, t0: float) -> None: + """Set the output to zero and keep internal states at zero. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["u"] = np.zeros((1, 1), dtype=float) - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): + def output_update(self, t: float, dt: float) -> None: + """Compute the PID control command from the current error input. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``e`` is not connected. + """ e_in = self.inputs["e"] if e_in is None: raise RuntimeError(f"[{self.name}] Missing input 'e'.") @@ -170,7 +183,6 @@ def output_update(self, t: float, dt: float): u = P + I + D - # Saturation if self.u_min is not None: u = np.maximum(u, self.u_min) if self.u_max is not None: @@ -178,8 +190,16 @@ def output_update(self, t: float, dt: float): self.outputs["u"] = u - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): + def state_update(self, t: float, dt: float) -> None: + """Update the integrator state and store the previous error. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``e`` is not connected. + """ e_in = self.inputs["e"] if e_in is None: raise RuntimeError(f"[{self.name}] Missing input 'e'.") @@ -188,13 +208,12 @@ def state_update(self, t: float, dt: float): has_i = "I" in self.controller - # Integrator update only if I term is enabled if has_i: x_i_next = self.state["x_i"] + self.Ki * e * dt else: x_i_next = self.state["x_i"].copy() - # Anti-windup (clamp integral state if saturation is defined) + # Anti-windup: clamp integral state to saturation bounds if self.u_min is not None: x_i_next = np.maximum(x_i_next, self.u_min) if self.u_max is not None: @@ -207,10 +226,9 @@ def state_update(self, t: float, dt: float): # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_siso(self, name: str, value: ArrayLike) -> np.ndarray: - """ - Normalize scalar-like into (1,1). Reject anything else (strict SISO). - """ + """Normalize a scalar-like value to a (1,1) array; reject anything else.""" if np.isscalar(value): return np.array([[float(value)]], dtype=float) @@ -227,8 +245,8 @@ def _to_siso(self, name: str, value: ArrayLike) -> np.ndarray: f"[{self.name}] '{name}' must be scalar-like ((), (1,), or (1,1)). Got shape {arr.shape}." ) - # ------------------------------------------------------------------ def _validate_gains(self) -> None: + """Warn if a gain is zero for a mode that requires it.""" kp = float(self.Kp[0, 0]) ki = float(self.Ki[0, 0]) kd = float(self.Kd[0, 0]) diff --git a/pySimBlocks/blocks/controllers/state_feedback.py b/pySimBlocks/blocks/controllers/state_feedback.py index b356ebc..108b5de 100644 --- a/pySimBlocks/blocks/controllers/state_feedback.py +++ b/pySimBlocks/blocks/controllers/state_feedback.py @@ -18,45 +18,44 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np +from numpy.typing import ArrayLike + from pySimBlocks.core.block import Block class StateFeedback(Block): - """ - Discrete-time state-feedback controller block. - - Summary: - Implements a static discrete-time state-feedback control law: - u = G @ r - K @ x - - Parameters: - K : array-like, shape (m, n) - State feedback gain matrix. - G : array-like, shape (m, p) - Reference feedforward gain matrix. - sample_time : float, optional - Block execution period. - - Inputs: - r : array (p, 1) - Reference vector. - x : array (n, 1) - State vector. - - Outputs: - u : array (m, 1) - Control vector. - - Notes: - - Stateless block. - - This block intentionally enforces column-vector inputs. - - No implicit flattening is performed. + """Discrete-time state-feedback controller block. + + Implements a static discrete-time state-feedback control law: + + u = G @ r - K @ x + + Both inputs must be column vectors. No implicit flattening is performed. + + Attributes: + K: State feedback gain matrix of shape (m, n). + G: Reference feedforward gain matrix of shape (m, p). """ direct_feedthrough = True - def __init__(self, name: str, K, G, sample_time: float | None = None): + def __init__(self, name: str, K: ArrayLike, G: ArrayLike, sample_time: float | None = None): + """Initialize a StateFeedback block. + + Args: + name: Unique identifier for this block instance. + K: State feedback gain matrix, array-like of shape (m, n). + G: Reference feedforward gain matrix, array-like of shape (m, p). + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If K or G are not 2D, or if their first dimensions + do not match. + """ super().__init__(name, sample_time) self.K = np.asarray(K, dtype=float) @@ -76,23 +75,27 @@ def __init__(self, name: str, K, G, sample_time: float | None = None): f"K is {self.K.shape} while G is {self.G.shape} (first dimension must match)." ) - # cached expected sizes for input validation self._m = m self._n = n self._p = p - # Ports self.inputs["r"] = None self.inputs["x"] = None self.outputs["u"] = None - # freeze input shapes once seen (optional but consistent) self._input_shapes = {} + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): + + def initialize(self, t0: float) -> None: + """Set the output to zero, or compute u if inputs are already available. + + Args: + t0: Initial simulation time in seconds. + """ r = self.inputs["r"] x = self.inputs["x"] if r is None or x is None: @@ -106,22 +109,45 @@ def initialize(self, t0: float): except Exception as _: self.outputs["u"] = np.zeros((self._m, 1)) - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): + def output_update(self, t: float, dt: float) -> None: + """Compute the control output u = G @ r - K @ x. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``r`` or ``x`` is not connected. + ValueError: If input shapes do not match the gain matrices. + """ r = self._require_col_vector("r", self._p) x = self._require_col_vector("x", self._n) self.outputs["u"] = self.G @ r - self.K @ x - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): - pass + def state_update(self, t: float, dt: float) -> None: + """No-op: StateFeedback carries no internal state.""" # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _require_col_vector(self, port: str, expected_rows: int) -> np.ndarray: + """Validate and return an input port value as a column vector. + + Args: + port: Name of the input port to read. + expected_rows: Expected number of rows in the column vector. + + Returns: + The input value as a 2D (n, 1) float array. + + Raises: + RuntimeError: If the port value is None. + ValueError: If the array is not a column vector or has the + wrong number of rows. + """ u = self.inputs[port] if u is None: raise RuntimeError(f"[{self.name}] Input '{port}' is not connected or not set.") diff --git a/pySimBlocks/blocks/interfaces/external_input.py b/pySimBlocks/blocks/interfaces/external_input.py index 8e86bb3..e69d477 100644 --- a/pySimBlocks/blocks/interfaces/external_input.py +++ b/pySimBlocks/blocks/interfaces/external_input.py @@ -23,33 +23,24 @@ class ExternalInput(Block): - """ - External input interface block. - - Summary: - Pass-through block for external real-time measurements. - - Parameters: - sample_time: float, optional - Execution period of the block. If not specified, the simulator time - step is used. - - I/O: - Input: - in: array (n,1) - External measurement value. Scalar, (n,), (n,1) accepted. - Output: - out: array (n,1) - Measurement forwarded to the model as a column vector (n,1). - Policy: - - Accepts scalar, (n,), (n,1) - - Outputs strict (n,1) - - Once shape is known, it is frozen (cannot change) + """External input interface block. + + Pass-through block for injecting real-time measurements into the model. + Accepts scalar, (n,) or (n,1) inputs and forwards them as a strict (n,1) + column vector. The output shape is frozen after the first non-None input + and cannot change during the simulation. """ direct_feedthrough = True def __init__(self, name: str, sample_time: float | None = None): + """Initialize an ExternalInput block. + + Args: + name: Unique identifier for this block instance. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ super().__init__(name, sample_time) self.inputs["in"] = None self.outputs["out"] = None @@ -59,7 +50,13 @@ def __init__(self, name: str, sample_time: float | None = None): # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to zero if no input is available, or forward the input. + + Args: + t0: Initial simulation time in seconds. + """ u = self.inputs["in"] if u is None: self.outputs["out"] = np.zeros((1, 1)) @@ -67,22 +64,32 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = self._to_col_vec(u) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Forward the input to the output as a column vector. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``in`` is not set. + ValueError: If the input shape is incompatible or has changed. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Missing input 'in'.") self.outputs["out"] = self._to_col_vec(u) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - pass + """No-op: ExternalInput carries no internal state.""" # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_col_vec(self, value) -> np.ndarray: + """Normalize value to a (n,1) column vector and enforce frozen shape.""" arr = np.asarray(value, dtype=float) if arr.ndim == 0: @@ -96,7 +103,6 @@ def _to_col_vec(self, value) -> np.ndarray: f"[{self.name}] Input 'in' must be scalar, (n,), or (n,1). Got shape {arr.shape}." ) - # Freeze shape if self._resolved_shape is None: self._resolved_shape = arr.shape elif arr.shape != self._resolved_shape: diff --git a/pySimBlocks/blocks/interfaces/external_output.py b/pySimBlocks/blocks/interfaces/external_output.py index 5b31e8a..30a1d50 100644 --- a/pySimBlocks/blocks/interfaces/external_output.py +++ b/pySimBlocks/blocks/interfaces/external_output.py @@ -23,33 +23,24 @@ class ExternalOutput(Block): + """External output interface block. + + Pass-through block for exposing model signals to the real-time external + side. Accepts scalar, (n,) or (n,1) inputs and forwards them as a strict + (n,1) column vector. The output shape is frozen after the first non-None + input and cannot change during the simulation. """ - External output interface block. - - Summary: - Pass-through block for external real-time commands/values. - - Parameters: - sample_time: float, optional - Execution period of the block. If not specified, the simulator time - step is used. - - I/O: - Input: - in: array (n,1) - Value produced by the model. Scalar, (n,), (n,1) accepted. - Output: - out: array (n,1) - Value forwarded to the external side as a column vector (n,1). - - Policy: - - Accepts scalar, (n,), (n,1) - - Outputs strict (n,1) - - Once shape is known, it is frozen (cannot change) - """ + direct_feedthrough = True def __init__(self, name: str, sample_time: float | None = None): + """Initialize an ExternalOutput block. + + Args: + name: Unique identifier for this block instance. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ super().__init__(name, sample_time) self.inputs["in"] = None self.outputs["out"] = None @@ -59,7 +50,13 @@ def __init__(self, name: str, sample_time: float | None = None): # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to None if no input is available, or forward the input. + + Args: + t0: Initial simulation time in seconds. + """ u = self.inputs["in"] if u is None: self.outputs["out"] = None @@ -67,42 +64,45 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = self._to_col_vec(u) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Forward the input to the output as a column vector. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``in`` is not set. + ValueError: If the input shape is incompatible or has changed. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Missing input 'in'.") self.outputs["out"] = self._to_col_vec(u) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - pass + """No-op: ExternalOutput carries no internal state.""" # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_col_vec(self, value) -> np.ndarray: + """Normalize value to a (n,1) column vector and enforce frozen shape.""" arr = np.asarray(value, dtype=float) - # scalar if arr.ndim == 0: arr = arr.reshape(1, 1) - - # vector (n,) elif arr.ndim == 1: arr = arr.reshape(-1, 1) - - # column (n,1) elif arr.ndim == 2 and arr.shape[1] == 1: pass - else: raise ValueError( f"[{self.name}] Input 'in' must be scalar, (n,), or (n,1). Got shape {arr.shape}." ) - # Freeze shape if self._resolved_shape is None: self._resolved_shape = arr.shape elif arr.shape != self._resolved_shape: diff --git a/pySimBlocks/blocks/observers/luenberger.py b/pySimBlocks/blocks/observers/luenberger.py index 7f1d427..b871588 100644 --- a/pySimBlocks/blocks/observers/luenberger.py +++ b/pySimBlocks/blocks/observers/luenberger.py @@ -18,40 +18,31 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np +from numpy.typing import ArrayLike + from pySimBlocks.core.block import Block class Luenberger(Block): - """ - Discrete-time Luenberger state observer block. - - Summary: - Estimates the state using: - y_hat[k] = C x_hat[k] - x_hat[k+1] = A x_hat[k] + B u[k] + L (y[k] - y_hat[k]) - - Parameters: - A : array-like, shape (n, n) - B : array-like, shape (n, m) - C : array-like, shape (p, n) - L : array-like, shape (n, p) - x0 : array-like, shape (n, 1), optional - sample_time : float, optional - - Inputs: - u : array (m, 1) - y : array (p, 1) - - Outputs: - x_hat : array (n, 1) - y_hat : array (p, 1) - - Notes: - - Stateful block. - - No direct feedthrough. - - D matrix intentionally not supported. - - Input shapes are frozen once first seen. + """Discrete-time Luenberger state observer block. + + Estimates the state of a linear system using the correction law: + + y_hat[k] = C x_hat[k] + + x_hat[k+1] = A x_hat[k] + B u[k] + L (y[k] - y_hat[k]) + + The D matrix is intentionally not supported. Input column-vector shapes + are frozen after the first call and must remain constant. + + Attributes: + A: State transition matrix of shape (n, n). + B: Input matrix of shape (n, m). + C: Output matrix of shape (p, n). + L: Observer gain matrix of shape (n, p). """ direct_feedthrough = False @@ -59,16 +50,32 @@ class Luenberger(Block): def __init__( self, name: str, - A, - B, - C, - L, - x0=None, + A: ArrayLike, + B: ArrayLike, + C: ArrayLike, + L: ArrayLike, + x0: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a Luenberger observer block. + + Args: + name: Unique identifier for this block instance. + A: State transition matrix, array-like of shape (n, n). + B: Input matrix, array-like of shape (n, m). + C: Output matrix, array-like of shape (p, n). + L: Observer gain matrix, array-like of shape (n, p). + x0: Initial state estimate, array-like of shape (n, 1). + Defaults to zeros. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If any matrix is not 2D, if dimensions are + inconsistent, or if x0 does not have shape (n, 1). + """ super().__init__(name, sample_time) - # --- Matrices: strict 2D self.A = np.asarray(A, dtype=float) self.B = np.asarray(B, dtype=float) self.C = np.asarray(C, dtype=float) @@ -98,7 +105,6 @@ def __init__( self._m = m self._p = p - # --- Initial state x0: strict (n,1), no flatten if x0 is None: x0_arr = np.zeros((n, 1), dtype=float) else: @@ -109,33 +115,51 @@ def __init__( self.state["x_hat"] = x0_arr.copy() self.next_state["x_hat"] = x0_arr.copy() - # --- Ports self.inputs["u"] = None self.inputs["y"] = None self.outputs["y_hat"] = None self.outputs["x_hat"] = None - # Freeze input shapes once first seen self._input_shapes = {} # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set initial outputs from the initial state estimate. + + Args: + t0: Initial simulation time in seconds. + """ x_hat = self.state["x_hat"] self.outputs["x_hat"] = x_hat.copy() self.outputs["y_hat"] = self.C @ x_hat self.next_state["x_hat"] = x_hat.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Compute x_hat and y_hat outputs from the committed state. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ x_hat = self.state["x_hat"] self.outputs["x_hat"] = x_hat.copy() self.outputs["y_hat"] = self.C @ x_hat - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: + """Update the state estimate using the observer correction law. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If inputs ``u`` or ``y`` are not connected. + ValueError: If input shapes are incompatible or have changed. + """ u = self._require_col_vector("u", self._m) y = self._require_col_vector("y", self._p) @@ -148,14 +172,28 @@ def state_update(self, t: float, dt: float) -> None: # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _require_col_vector(self, port: str, expected_rows: int) -> np.ndarray: + """Validate and return an input port value as a column vector. + + Args: + port: Name of the input port to read. + expected_rows: Expected number of rows in the column vector. + + Returns: + The input value as a 2D (n, 1) float array. + + Raises: + RuntimeError: If the port value is None. + ValueError: If the array is not a column vector, has the wrong + number of rows, or its shape has changed since the first call. + """ val = self.inputs[port] if val is None: raise RuntimeError(f"[{self.name}] Input '{port}' is not connected or not set.") arr = np.asarray(val, dtype=float) - # Strict: column vector only (no implicit flatten) if arr.ndim != 2 or arr.shape[1] != 1: raise ValueError(f"[{self.name}] Input '{port}' must be a column vector (n,1). Got {arr.shape}.") @@ -164,7 +202,6 @@ def _require_col_vector(self, port: str, expected_rows: int) -> np.ndarray: f"[{self.name}] Input '{port}' has wrong dimension: expected ({expected_rows},1), got {arr.shape}." ) - # Freeze shape if port not in self._input_shapes: self._input_shapes[port] = arr.shape elif arr.shape != self._input_shapes[port]: diff --git a/pySimBlocks/blocks/operators/algebraic_function.py b/pySimBlocks/blocks/operators/algebraic_function.py index 49ba9fd..5ed20ae 100644 --- a/pySimBlocks/blocks/operators/algebraic_function.py +++ b/pySimBlocks/blocks/operators/algebraic_function.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import importlib.util import inspect from pathlib import Path @@ -29,28 +31,19 @@ class AlgebraicFunction(Block): - """ - User-defined algebraic function block. - - Summary: - Stateless block defined by a user-provided Python function: - y = g(t, dt, u1, u2, ...) - - Parameters: - function : callable - User-defined function. - input_keys : list[str] - Names of input ports. - output_keys : list[str] - Names of output ports. - sample_time : float, optional - Block execution period. - - Notes: - - Stateless. - - Function must return a dict with exactly output_keys. - - Inputs/outputs must be 2D numpy arrays (matrices allowed). - - Input/output shapes are frozen per port after first resolution. + """User-defined algebraic function block. + + Stateless block defined by a user-provided Python callable: + + y = g(t, dt, u1, u2, ...) + + Input and output port names are declared dynamically from ``input_keys`` + and ``output_keys``. All inputs and outputs must be 2D numpy arrays. Input + and output shapes are frozen per port after the first use. + + Attributes: + input_keys: Names of the input ports. + output_keys: Names of the output ports. """ direct_feedthrough = True @@ -64,6 +57,22 @@ def __init__( output_keys: List[str], sample_time: float | None = None, ): + """Initialize an AlgebraicFunction block. + + Args: + name: Unique identifier for this block instance. + function: User-defined callable with signature + ``g(t, dt, **inputs) -> dict`` returning a dict mapping each + key in ``output_keys`` to a 2D numpy array. + input_keys: Names of the input ports. + output_keys: Names of the output ports. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + TypeError: If ``function`` is not callable. + ValueError: If ``input_keys`` or ``output_keys`` are empty. + """ super().__init__(name=name, sample_time=sample_time) if function is None or not callable(function): @@ -81,22 +90,37 @@ def __init__( self.inputs: Dict[str, np.ndarray | None] = {k: None for k in self.input_keys} self.outputs: Dict[str, np.ndarray | None] = {k: None for k in self.output_keys} - # Shape freeze per port self._in_shapes: Dict[str, tuple[int, int] | None] = {k: None for k in self.input_keys} self._out_shapes: Dict[str, tuple[int, int] | None] = {k: None for k in self.output_keys} + # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod - def adapt_params(cls, - params: Dict[str, Any], - params_dir: Path | None = None) -> Dict[str, Any]: + def adapt_params( + cls, + params: Dict[str, Any], + params_dir: Path | None = None, + ) -> Dict[str, Any]: + """Load the user function from ``file_path`` and ``function_name`` YAML keys. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. Must not be None. + + Returns: + Parameter dict with ``function`` set to the loaded callable and + ``file_path`` / ``function_name`` keys removed. + + Raises: + ValueError: If ``params_dir`` is None or required keys are missing. + FileNotFoundError: If the function file does not exist. + AttributeError: If the named function is not found in the module. + TypeError: If the resolved attribute is not callable. """ - Adapt parameters from yaml format to class constructor format. - Adapt function file and name in a yaml format into callable. - """ - # --- 1. Extract function file and name if params_dir is None: raise ValueError("parameters_dir must be provided for AlgebraicFunction adapter.") try: @@ -107,7 +131,6 @@ def adapt_params(cls, f"AlgebraicFunction adapter missing parameter: {e}" ) - # --- 2. Resolve file path (relative to project.yaml directory) path = Path(file_path).expanduser() if not path.is_absolute(): path = (params_dir / path).resolve() @@ -115,13 +138,11 @@ def adapt_params(cls, if not path.exists(): raise FileNotFoundError(f"Function file not found: {path}") - # --- 3. Load module spec = importlib.util.spec_from_file_location(path.stem, path) module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) - # --- 4. Extract function try: func = getattr(module, func_name) except AttributeError: @@ -134,7 +155,6 @@ def adapt_params(cls, f"'{func_name}' in {path} is not callable" ) - # --- 5. Build adapted parameter dict adapted = dict(params) adapted.pop("file_path", None) adapted.pop("function_name", None) @@ -144,9 +164,20 @@ def adapt_params(cls, # -------------------------------------------------------------------------- - # Public Methods + # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): + + def initialize(self, t0: float) -> None: + """Validate the function signature and compute initial outputs. + + Args: + t0: Initial simulation time in seconds. + + Raises: + ValueError: If the function signature does not match ``input_keys``. + RuntimeError: If the function does not return the expected output + keys. + """ self._validate_signature() out = self._call_func(t0, 0, **self.inputs) @@ -161,41 +192,53 @@ def initialize(self, t0: float): for k in self.output_keys: self.outputs[k] = out[k] - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): - # collect inputs + def output_update(self, t: float, dt: float) -> None: + """Call the user function and write outputs to the output ports. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If any input is not set or the function fails. + TypeError: If any input or output is not a numpy array. + ValueError: If any input or output is not 2D, or if shapes changed. + """ kwargs: Dict[str, np.ndarray] = {} for k in self.input_keys: u = self.inputs[k] if u is None: raise RuntimeError(f"[{self.name}] input '{k}' is not set.") - u = np.asarray(u) # allow array-like injection, but freeze as ndarray 2D + u = np.asarray(u) self._check_freeze_shape("input", k, u, self._in_shapes) kwargs[k] = u - # call function out = self._call_func(t, dt, **kwargs) - - - # assign outputs for k in self.output_keys: y = out[k] y = np.asarray(y) self._check_freeze_shape("output", k, y, self._out_shapes) self.outputs[k] = y - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): - return # stateless + def state_update(self, t: float, dt: float) -> None: + """No-op: AlgebraicFunction is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- - # Private Methods + # Private methods # -------------------------------------------------------------------------- + def _call_func(self, t: float, dt: float, **kwargs) -> Dict[str, np.ndarray]: + """Invoke the user function and validate its output dict.""" try: - out = self._func(t, dt, **kwargs) + out = self._func(t, dt, **kwargs) except Exception as e: raise RuntimeError(f"[{self.name}] function call error: {e}\n" f"Must always return a dict with output keys: {self.output_keys}") @@ -217,8 +260,8 @@ def _call_func(self, t: float, dt: float, **kwargs) -> Dict[str, np.ndarray]: raise RuntimeError(f"{self.name}: output '{k}' must be at most 2D (got shape {y.shape})") return out - # ------------------------------------------------------------------ def _validate_signature(self) -> None: + """Raise if the function signature does not match (t, dt, *input_keys).""" sig = inspect.signature(self._func) params = list(sig.parameters.values()) @@ -228,7 +271,6 @@ def _validate_signature(self) -> None: if params[0].name != "t" or params[1].name != "dt": raise ValueError(f"[{self.name}] first arguments must be (t, dt).") - # no *args / **kwargs / defaults for p in params: if p.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD,): raise ValueError(f"[{self.name}] *args and **kwargs are not allowed.") @@ -241,8 +283,8 @@ def _validate_signature(self) -> None: f"Function declares: {declared}" ) - # ------------------------------------------------------------------ def _check_freeze_shape(self, which: str, key: str, arr: np.ndarray, store: Dict[str, tuple[int, int] | None]) -> None: + """Validate that an array is 2D and freeze its shape on the first call.""" if not isinstance(arr, np.ndarray): raise TypeError(f"[{self.name}] {which} '{key}' is not a numpy array.") if arr.ndim != 2: diff --git a/pySimBlocks/blocks/operators/dead_zone.py b/pySimBlocks/blocks/operators/dead_zone.py index 4c7f30a..0594df0 100644 --- a/pySimBlocks/blocks/operators/dead_zone.py +++ b/pySimBlocks/blocks/operators/dead_zone.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,40 +27,27 @@ class DeadZone(Block): - """ - Discrete-time dead zone operator. - - Summary: - Suppresses the input signal within a specified interval around zero - and shifts the signal outside this interval. - - Parameters: - lower_bound : scalar or array-like - Lower bound of the dead zone (must be <= 0 component-wise). - upper_bound : scalar or array-like - Upper bound of the dead zone (must be >= 0 component-wise). - sample_time : float, optional - Block execution period. - - Inputs: - in : array (m,n) - Input signal (must be 2D). - - Outputs: - out : array (m,n) - Output signal after dead-zone transformation. - - Notes: - - Stateless block. - - Direct feedthrough. - - Bounds are applied component-wise. - - Broadcasting rules (to match input shape (m,n)): - * scalar (1,1) broadcasts to (m,n) - * vector (m,1) broadcasts across columns to (m,n) - * matrix (m,n) must match exactly - - The dead zone must include zero component-wise: - lower_bound <= 0 <= upper_bound. - - Once resolved, input shape must remain constant. + """Discrete-time dead-zone operator. + + Suppresses the input within a specified interval and shifts the signal + outside it: + + y = 0 if lower_bound <= u <= upper_bound + + y = u - upper_bound if u > upper_bound + + y = u - lower_bound if u < lower_bound + + Bounds are applied component-wise and resolved on the first call. Once the + input shape is resolved it must remain constant. + + Attributes: + lower_raw: Raw lower bound array before broadcasting. + upper_raw: Raw upper bound array before broadcasting. + lower_bound: Broadcasted lower bound matched to the input shape, or + None before the first resolution. + upper_bound: Broadcasted upper bound matched to the input shape, or + None before the first resolution. """ direct_feedthrough = True @@ -70,6 +59,22 @@ def __init__( upper_bound: ArrayLike = 0.0, sample_time: float | None = None, ): + """Initialize a DeadZone block. + + Args: + name: Unique identifier for this block instance. + lower_bound: Lower bound of the dead zone. Must be <= 0 + component-wise. Accepted shapes: scalar, 1D vector, or 2D + matrix. + upper_bound: Upper bound of the dead zone. Must be >= 0 + component-wise. Accepted shapes: scalar, 1D vector, or 2D + matrix. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If bounds cannot be converted to a 2D array. + """ super().__init__(name, sample_time) self.inputs["in"] = None @@ -84,9 +89,19 @@ def __init__( # -------------------------------------------------------------------------- - # Private methods + # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Resolve bounds from the initial input and compute the initial output. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None at initialization. + ValueError: If input is not 2D or bounds have incompatible shapes. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.") @@ -100,8 +115,18 @@ def initialize(self, t0: float) -> None: self._resolve_for_input(u) self.outputs["out"] = self._apply_dead_zone(u) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Apply the dead zone to the input and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None. + ValueError: If input is not 2D or its shape changed after + initialization. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None.") @@ -115,28 +140,32 @@ def output_update(self, t: float, dt: float) -> None: self._resolve_for_input(u) self.outputs["out"] = self._apply_dead_zone(u) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: DeadZone is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _broadcast_bound(self, b: np.ndarray, target_shape: tuple[int, int], name: str) -> np.ndarray: + """Broadcast a bound array to the target input shape.""" m, n = target_shape - # scalar -> full matrix if self._is_scalar_2d(b): return np.full(target_shape, float(b[0, 0]), dtype=float) - # vector (m,1) -> broadcast across columns if b.ndim == 2 and b.shape[1] == 1 and b.shape[0] == m: if n == 1: return b.astype(float, copy=False) return np.repeat(b.astype(float, copy=False), n, axis=1) - # matrix -> must match exactly if b.shape == target_shape: return b.astype(float, copy=False) @@ -145,8 +174,8 @@ def _broadcast_bound(self, b: np.ndarray, target_shape: tuple[int, int], name: s f"Allowed: scalar (1,1), vector (m,1), or matrix (m,n)." ) - # ------------------------------------------------------------------ def _resolve_for_input(self, u: np.ndarray) -> None: + """Broadcast and validate bounds against the input shape on first call.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." @@ -157,7 +186,6 @@ def _resolve_for_input(self, u: np.ndarray) -> None: self.lower_bound = self._broadcast_bound(self.lower_raw, u.shape, "lower_bound") self.upper_bound = self._broadcast_bound(self.upper_raw, u.shape, "upper_bound") - # Component-wise validity checks (after broadcasting) if np.any(self.lower_bound > self.upper_bound): raise ValueError(f"[{self.name}] lower_bound must be <= upper_bound (component-wise).") if np.any(self.lower_bound > 0): @@ -173,8 +201,8 @@ def _resolve_for_input(self, u: np.ndarray) -> None: f"expected {self._resolved_shape}, got {u.shape}." ) - # ------------------------------------------------------------------ def _apply_dead_zone(self, u: np.ndarray) -> np.ndarray: + """Compute the dead-zone output for a validated input array.""" y = np.zeros_like(u) above = u > self.upper_bound diff --git a/pySimBlocks/blocks/operators/delay.py b/pySimBlocks/blocks/operators/delay.py index 7f12adc..014a06e 100644 --- a/pySimBlocks/blocks/operators/delay.py +++ b/pySimBlocks/blocks/operators/delay.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,46 +27,20 @@ class Delay(Block): - """ - N-step discrete delay block. - - Summary: - Outputs a delayed version of the input signal by a fixed number of - discrete time steps. - - Parameters (overview): - num_delays : int - Number of discrete delays N (N >= 1). - initial_output : scalar or array-like, optional - Initial value used to fill the delay buffer. - Accepted: scalar -> (1,1), 1D -> (k,1), 2D -> (m,n). - Scalar (1,1) can be broadcast to match the input shape when the - first input becomes available. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - in : Input signal (must be 2D). - reset: Optional reset signal. - Outputs: - out : Delayed output signal (2D). - - Notes: - - Stateful block. - - No direct feedthrough. - - Output at time k is the input at time k − N. - - Buffer shape is inferred from the first available input if not - explicitly initialized. - - Policy: - + Signals are 2D arrays. - + Buffer always exists (never None). - + Shape is fixed either: - * immediately if initial_output is non-scalar 2D (shape != (1,1)) - * otherwise at the first non-None input seen by the block - + If buffer is still "unfixed" and currently scalar (1,1), it can be - broadcast ONCE to match the first input shape. - + After shape is fixed, any shape mismatch raises. + """N-step discrete delay block. + + Outputs a delayed version of the input signal by a fixed number of discrete + time steps. The output at time k is the input at time k − N: + + y[k] = u[k - N] + + The buffer shape is inferred from the first non-None input unless an + explicit ``initial_output`` of non-scalar shape is provided. A scalar (1,1) + initial value is broadcast to match the first input. Once the shape is + fixed, any mismatch raises an error. + + Attributes: + num_delays: Number of discrete steps N (>= 1). """ direct_feedthrough = False @@ -76,6 +52,21 @@ def __init__( initial_output: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a Delay block. + + Args: + name: Unique identifier for this block instance. + num_delays: Number of discrete steps to delay the input. Must be + >= 1. + initial_output: Initial value used to fill the delay buffer. + Accepted shapes: scalar, 1D, or 2D. A non-scalar 2D value + fixes the buffer shape immediately. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If ``num_delays`` is not a positive integer. + """ super().__init__(name, sample_time) if not isinstance(num_delays, int) or num_delays < 1: @@ -89,11 +80,9 @@ def __init__( self.state["buffer"] = None self.next_state["buffer"] = None - # Shape management self._shape_fixed: bool = False self._buffer_shape: tuple[int, int] | None = None - # Initialize buffer as (1,1) by default, but NOT fixed yet. self._initial_output = initial_output init = np.zeros((1, 1), dtype=float) @@ -101,12 +90,10 @@ def __init__( arr = self._to_2d_array("initial_output", initial_output) init = arr.astype(float, copy=False) - # If user provides a non-scalar 2D initial_output, shape is fixed now. if not self._is_scalar_2d(init): self._shape_fixed = True self._buffer_shape = init.shape - # Buffer always exists (never None) self.state["buffer"] = [init.copy() for _ in range(self.num_delays)] self.next_state["buffer"] = None @@ -114,7 +101,17 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the initial output from the buffer, resolving shape if input is available. + + Args: + t0: Initial simulation time in seconds. + + Raises: + ValueError: If the initial output shape is inconsistent with the + resolved buffer shape. + """ out = self.state["buffer"][0] u = self.inputs["in"] @@ -132,8 +129,13 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = out - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Output the oldest buffer entry. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ if not self._shape_fixed: u = self.inputs["in"] if u is not None: @@ -142,8 +144,18 @@ def output_update(self, t: float, dt: float) -> None: self.outputs["out"] = self.state["buffer"][0].copy() - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: + """Shift the buffer left and append the current input. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is not connected. + ValueError: If the input is not 2D or its shape is inconsistent + with the buffer. + """ if self._is_reset_active(): self._apply_reset() return @@ -158,7 +170,6 @@ def state_update(self, t: float, dt: float) -> None: buffer = self.state["buffer"] - # Shift left and append u new_buffer = [] for i in range(self.num_delays - 1): new_buffer.append(buffer[i + 1].copy()) @@ -166,17 +177,13 @@ def state_update(self, t: float, dt: float) -> None: self.next_state["buffer"] = new_buffer + # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _ensure_shape_and_buffer(self, u: np.ndarray) -> None: - """ - Ensure: - - u is 2D - - buffer exists - - shape is fixed at the right time - - after shape is fixed, input must match buffer shape - """ + """Validate input shape and fix the buffer shape on the first non-None input.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." @@ -185,7 +192,6 @@ def _ensure_shape_and_buffer(self, u: np.ndarray) -> None: buf0 = self.state["buffer"][0] assert buf0 is not None - # If already fixed, enforce strict match if self._shape_fixed: expected = buf0.shape if u.shape != expected: @@ -194,11 +200,8 @@ def _ensure_shape_and_buffer(self, u: np.ndarray) -> None: ) return - # Not fixed yet: decide whether we can/should fix now - # We fix the shape the first time we see a non-None input (whatever its shape is). target_shape = u.shape - # If buffer is scalar placeholder, broadcast it to target shape (one-time) if self._is_scalar_2d(buf0) and target_shape != (1, 1): scalar = float(buf0[0, 0]) self.state["buffer"] = [ @@ -206,20 +209,17 @@ def _ensure_shape_and_buffer(self, u: np.ndarray) -> None: ] buf0 = self.state["buffer"][0] - # If buffer is not scalar but we are not fixed yet, it must already match target shape - # (This can happen if you later decide to relax some init logic; keep strict.) if buf0.shape != target_shape: raise ValueError( f"[{self.name}] Cannot infer a consistent delay shape: " f"buffer currently {buf0.shape} but first input is {target_shape}." ) - # Now we can fix shape (including (1,1)) self._shape_fixed = True self._buffer_shape = target_shape - # ------------------------------------------------------------------ def _is_reset_active(self) -> bool: + """Return True if the reset signal is active (truthy scalar).""" reset_signal = self.inputs.get("reset", None) if reset_signal is None: return False @@ -236,6 +236,7 @@ def _is_reset_active(self) -> bool: ) def _apply_reset(self) -> None: + """Reset the buffer to the initial output or zeros.""" if self._initial_output is not None: arr = self._to_2d_array("initial_output", self._initial_output) init = arr.astype(float, copy=False) diff --git a/pySimBlocks/blocks/operators/demux.py b/pySimBlocks/blocks/operators/demux.py index a2dc3fc..d526696 100644 --- a/pySimBlocks/blocks/operators/demux.py +++ b/pySimBlocks/blocks/operators/demux.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,34 +27,31 @@ class Demux(Block): - """ - Vector split block (inverse of Mux). - - Summary: - Splits one input column vector into multiple output segments. - - Parameters: - num_outputs : int - Number of scalar outputs to produce. - sample_time : float, optional - Block execution period. - - Inputs: - in : vector (n,1) - Input must be a column vector. - - Outputs: - out1, out2, ..., outP : array (k,1) - Output segment sizes follow: - - q = n // p - - m = n % p - - first m outputs have size (q+1,1) - - remaining (p-m) outputs have size (q,1) + """Vector split block (inverse of Mux). + + Splits one input column vector of length n into p output segments. Segment + sizes are distributed as evenly as possible: let q = n // p and m = n % p, + then the first m outputs have size q+1 and the remaining p-m outputs have + size q. + + Attributes: + num_outputs: Number of output segments to produce. """ direct_feedthrough = True def __init__(self, name: str, num_outputs: int = 2, sample_time: float | None = None): + """Initialize a Demux block. + + Args: + name: Unique identifier for this block instance. + num_outputs: Number of output segments. Must be >= 1. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If ``num_outputs`` is not a positive integer. + """ super().__init__(name, sample_time) if not isinstance(num_outputs, int) or num_outputs < 1: @@ -67,7 +66,13 @@ def __init__(self, name: str, num_outputs: int = 2, sample_time: float | None = # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Compute initial outputs, or set zero placeholders if input is unavailable. + + Args: + t0: Initial simulation time in seconds. + """ if self.inputs["in"] is None: for i in range(self.num_outputs): self.outputs[f"out{i+1}"] = np.zeros((1, 1), dtype=float) @@ -75,19 +80,36 @@ def initialize(self, t0: float) -> None: self._compute_outputs() - # --------------------------------------------------------- def output_update(self, t: float, dt: float) -> None: + """Split the input vector and write the segments to the output ports. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is not connected. + ValueError: If input is not a column vector or has fewer elements + than ``num_outputs``. + """ self._compute_outputs() - # --------------------------------------------------------- def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: Demux is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_vector(self, value: ArrayLike) -> np.ndarray: + """Validate and return the input as a (n,1) column vector.""" arr = np.asarray(value, dtype=float) if arr.ndim != 2 or arr.shape[1] != 1: @@ -97,8 +119,8 @@ def _to_vector(self, value: ArrayLike) -> np.ndarray: ) return arr - # --------------------------------------------------------- def _compute_outputs(self) -> None: + """Split the input vector into output segments.""" u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is not connected or not set.") diff --git a/pySimBlocks/blocks/operators/discrete_derivator.py b/pySimBlocks/blocks/operators/discrete_derivator.py index f0cc122..e2e1000 100644 --- a/pySimBlocks/blocks/operators/discrete_derivator.py +++ b/pySimBlocks/blocks/operators/discrete_derivator.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,44 +27,20 @@ class DiscreteDerivator(Block): - """ - Discrete-time differentiator block. - - Summary: - Estimates the discrete-time derivative of the input signal using a - backward finite difference: - y[k] = (u[k] - u[k-1]) / dt - - Parameters: - initial_output : scalar or array-like, optional - Output used at the first execution step. - If provided, it also FIXES the signal shape permanently. - sample_time : float, optional - Block execution period. - - Inputs: - in : array (m,n) - Input signal (must be 2D). - - Outputs: - out : array (m,n) - Estimated discrete-time derivative. - - Notes: - - Stateful block. - - Direct feedthrough. - - Shape is frozen as soon as known (initial_output or first input). - - No implicit vector reshape; matrices are supported. - - Policy: - - Never propagate None: output is always at least (1,1) zeros. - - Shape is unresolved while only scalar placeholder (1,1) is seen and no - explicit initial_output is provided. - - If initial_output is provided -> shape is frozen immediately (including (1,1)). - - If no initial_output -> shape is frozen as soon as a non-scalar input arrives. - - Once shape is frozen -> any non-scalar mismatch raises ValueError. - - If shape frozen to (m,n) and input is scalar (1,1) -> broadcast scalar to (m,n). - - When shape is frozen from a first non-scalar input, u_prev is initialized to - that input to avoid bogus derivative spikes. + """Discrete-time differentiator block. + + Estimates the derivative of the input using a backward finite difference: + + y[k] = (u[k] - u[k-1]) / dt + + The output shape is resolved from the first non-scalar input and then + frozen. If an ``initial_output`` is provided it immediately fixes the shape. + A scalar (1,1) input is broadcast to the frozen shape once it is known. + The output is never ``None`` — a zero placeholder is used when no shape + information is yet available. + + Attributes: + initial_output: Initial output value, or None if not provided. """ direct_feedthrough = True @@ -73,6 +51,15 @@ def __init__( initial_output: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a DiscreteDerivator block. + + Args: + name: Unique identifier for this block instance. + initial_output: Output used at the first execution step. If + provided, it also fixes the signal shape permanently. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + """ super().__init__(name, sample_time) self.inputs["in"] = None @@ -84,7 +71,6 @@ def __init__( self._resolved_shape: tuple[int, int] | None = None self._first_output = True - # Never None: placeholder output at minimum self._placeholder = np.zeros((1, 1), dtype=float) self._initial_output_raw: np.ndarray | None = None @@ -92,7 +78,6 @@ def __init__( y0 = self._to_2d_array("initial_output", initial_output).astype(float) self._initial_output_raw = y0.copy() - # initial_output freezes shape (including (1,1)) self._resolved_shape = y0.shape self.outputs["out"] = y0.copy() else: @@ -102,17 +87,16 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: - """ - Never propagate None: - - output already set (initial_output or placeholder (1,1)). - - if input exists, we can set u_prev consistently. - - if input missing, keep u_prev=None (or already set if frozen on first non-scalar later). + """Set the previous-input state and prepare the initial output. + + Args: + t0: Initial simulation time in seconds. """ u = self.inputs["in"] if u is None: - # Keep output as-is (initial_output or placeholder) self.state["u_prev"] = None self.next_state["u_prev"] = None self._first_output = True @@ -120,27 +104,26 @@ def initialize(self, t0: float) -> None: u_arr = self._normalize_input(u) - # If initial_output froze shape, _normalize_input enforces it. - # Store u_prev to support derivative at next step. self.state["u_prev"] = u_arr.copy() self.next_state["u_prev"] = u_arr.copy() self._first_output = True - # ------------------------------------------------------- def output_update(self, t: float, dt: float) -> None: - """ - First call policy: - - If initial_output provided: keep it for first output_update. - - Else: keep zero output for first call. + """Compute the finite-difference derivative and write it to the output port. + + At the first call the output is held at ``initial_output`` (or zero if + none was provided). Afterwards: + + y = (u - u_prev) / dt - Afterwards: - y = (u - u_prev) / dt + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. """ u_arr = self._normalize_input(self.inputs["in"]) if self._first_output: self._first_output = False - # output already set (initial_output or placeholder); ensure shape if frozen if self._resolved_shape is not None and self.outputs["out"] is not None: y = np.asarray(self.outputs["out"], dtype=float) if y.shape == (1, 1) and self._resolved_shape != (1, 1): @@ -149,11 +132,9 @@ def output_update(self, t: float, dt: float) -> None: u_prev = self.state["u_prev"] if u_prev is None: - # No previous value -> define derivative as zero (same shape as u) self.outputs["out"] = np.zeros_like(u_arr) return - # If shape frozen and u_prev is scalar, broadcast it u_prev_arr = np.asarray(u_prev, dtype=float) if self._resolved_shape is not None and u_prev_arr.shape == (1, 1) and self._resolved_shape != (1, 1): u_prev_arr = np.full(self._resolved_shape, float(u_prev_arr[0, 0]), dtype=float) @@ -165,8 +146,13 @@ def output_update(self, t: float, dt: float) -> None: self.outputs["out"] = (u_arr - u_prev_arr) / dt - # ------------------------------------------------------- def state_update(self, t: float, dt: float) -> None: + """Store the current input as the previous value for the next step. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ u_arr = self._normalize_input(self.inputs["in"]) self.next_state["u_prev"] = u_arr.copy() @@ -174,42 +160,30 @@ def state_update(self, t: float, dt: float) -> None: # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _maybe_freeze_shape_from(self, u: np.ndarray) -> None: - """ - Freeze shape if: - - no initial_output has already frozen it - - shape unresolved - - u is non-scalar (shape != (1,1)) - """ + """Freeze the signal shape from the first non-scalar input.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." ) - # If shape is already frozen (by initial_output), nothing to do if self._resolved_shape is not None: return - # Only freeze when a non-scalar appears if u.shape != (1, 1): self._resolved_shape = u.shape - # Upgrade current output placeholder to correct shape (keep current scalar value) y = np.asarray(self.outputs["out"], dtype=float) if y.shape == (1, 1): scalar = float(y[0, 0]) self.outputs["out"] = np.full(self._resolved_shape, scalar, dtype=float) - # Initialize u_prev to current u to avoid a derivative spike self.state["u_prev"] = u.copy() self.next_state["u_prev"] = u.copy() - # ------------------------------------------------------- def _normalize_input(self, u: ArrayLike | None) -> np.ndarray: - """ - Normalize u to 2D, apply freezing rule and scalar broadcasting. - If u is None: return zeros of resolved shape if known, else (1,1) zeros. - """ + """Normalize input to 2D, applying shape freezing and scalar broadcasting.""" if u is None: if self._resolved_shape is not None: return np.zeros(self._resolved_shape, dtype=float) @@ -221,10 +195,8 @@ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray: f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u_arr.ndim} with shape {u_arr.shape}." ) - # If shape not frozen yet and u is non-scalar -> freeze now self._maybe_freeze_shape_from(u_arr) - # If shape is frozen: enforce or broadcast if self._resolved_shape is not None: if u_arr.shape == (1, 1) and self._resolved_shape != (1, 1): return np.full(self._resolved_shape, float(u_arr[0, 0]), dtype=float) diff --git a/pySimBlocks/blocks/operators/discrete_integrator.py b/pySimBlocks/blocks/operators/discrete_integrator.py index b33229e..8e9257f 100644 --- a/pySimBlocks/blocks/operators/discrete_integrator.py +++ b/pySimBlocks/blocks/operators/discrete_integrator.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,46 +27,25 @@ class DiscreteIntegrator(Block): - """ - Discrete-time integrator block. - - Summary: - Integrates an input signal over time using a discrete-time numerical - integration scheme. - - Parameters: - initial_state : scalar or array-like, optional - Initial value of the integrated state. If provided, it FIXES the signal shape. - method : str - Numerical integration method: "euler forward" or "euler backward". - sample_time : float, optional - Block execution period. - - Inputs: - in : array (m,n) - Signal to integrate (must be 2D). - - Outputs: - out : array (m,n) - Integrated signal. - - Notes: - - Stateful block. - - Direct feedthrough depends on method: - * euler forward -> False - * euler backward -> True - - Shape is frozen as soon as known (initial_state or first input). - - No implicit vector reshape; matrices are supported. - - Policy: - + Never propagate None: output is always a 2D array (at least (1,1)). - + Shape is NOT frozen while we are in placeholder shape (1,1) and no explicit - non-scalar initial_state was given. - + As soon as a non-scalar input appears (shape != (1,1)), the shape becomes frozen. - + If initial_state is provided and non-scalar, it freezes the shape immediately. - If initial_state is scalar (1,1), it is treated as a placeholder: can be upgraded - once a non-scalar input appears. - + After shape is frozen, any non-scalar input shape mismatch raises ValueError. - + If shape is frozen to (m,n), scalar input (1,1) is broadcast to (m,n). + """Discrete-time integrator block. + + Integrates an input signal over time using either Euler forward or Euler + backward integration. The state update is: + + x[k+1] = x[k] + dt * u[k] + + The output differs by method: + + y[k] = x[k] (Euler forward) + + y[k] = x[k] + dt * u[k] (Euler backward) + + Euler forward has no direct feedthrough; Euler backward does. The output + shape is resolved from the first non-scalar input and then frozen. A scalar + (1,1) input is broadcast to the frozen shape. The output is never ``None``. + + Attributes: + method: Integration method, ``'euler forward'`` or ``'euler backward'``. """ def __init__( @@ -74,6 +55,21 @@ def __init__( method: str = "euler forward", sample_time: float | None = None, ): + """Initialize a DiscreteIntegrator block. + + Args: + name: Unique identifier for this block instance. + initial_state: Initial value of the integrated state. If provided + and non-scalar, it fixes the signal shape immediately. + method: Integration method. Either ``'euler forward'`` or + ``'euler backward'``. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If ``method`` is not ``'euler forward'`` or + ``'euler backward'``. + """ super().__init__(name, sample_time) self.method = method.lower() @@ -83,21 +79,16 @@ def __init__( f"Allowed: 'euler forward', 'euler backward'." ) - # direct feedthrough policy self.direct_feedthrough = (self.method == "euler backward") - # ports self.inputs["in"] = None self.outputs["out"] = None - # shape policy self._resolved_shape: tuple[int, int] | None = None - # state self.state["x"] = None self.next_state["x"] = None - # Placeholder initialization (never None) self._placeholder = np.zeros((1, 1), dtype=float) self._initial_state_raw: np.ndarray | None = None @@ -105,7 +96,6 @@ def __init__( x0 = self._to_2d_array("initial_state", initial_state).astype(float) self._initial_state_raw = x0.copy() - # If non-scalar: freeze immediately. If scalar (1,1): keep unfrozen placeholder semantics. if x0.shape != (1, 1): self._resolved_shape = x0.shape @@ -121,50 +111,53 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: - # Never propagate None: guarantee placeholders even when no initial_state. + """Set the initial state and output from ``initial_state`` or a zero placeholder. + + Args: + t0: Initial simulation time in seconds. + """ if self._initial_state_raw is not None: x0 = self._initial_state_raw.copy() - - # If initial_state is non-scalar, freeze is already set in __init__. - # If scalar, keep unresolved unless later a non-scalar input appears. self.state["x"] = x0.copy() self.next_state["x"] = x0.copy() - - # output at init: - # forward -> y=x - # backward -> y=x + dt*u, but u may be unknown at init; we take u=0 (consistent with "no None") - if self.method == "euler forward": - self.outputs["out"] = x0.copy() - else: - self.outputs["out"] = x0.copy() - + self.outputs["out"] = x0.copy() else: self.state["x"] = self._placeholder.copy() self.next_state["x"] = self._placeholder.copy() self.outputs["out"] = self._placeholder.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Compute the output from the current state according to the integration method. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ x = self._normalize_state() if self.method == "euler forward": self.outputs["out"] = x.copy() return - # euler backward: y = x + dt*u u = self._normalize_input(self.inputs["in"]) self.outputs["out"] = x + dt * u - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - # Even if input is not available due to execution order, do not crash: - # treat missing as zeros (same idea: "0 if not defined"). - u = self._normalize_input(self.inputs["in"]) + """Advance the integrator state by one step. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + Raises: + ValueError: If the state and input shapes are inconsistent after + shape resolution. + """ + u = self._normalize_input(self.inputs["in"]) x = self._normalize_state() - # ensure x matches u when shape resolved by u if self._resolved_shape is not None: if x.shape != u.shape: raise ValueError( @@ -177,12 +170,9 @@ def state_update(self, t: float, dt: float) -> None: # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _maybe_freeze_shape_from(self, u: np.ndarray) -> None: - """ - Freeze shape if: - - shape not resolved yet - - and u is non-scalar (shape != (1,1)) - """ + """Freeze the signal shape from the first non-scalar input.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." @@ -191,7 +181,6 @@ def _maybe_freeze_shape_from(self, u: np.ndarray) -> None: if self._resolved_shape is None and u.shape != (1, 1): self._resolved_shape = u.shape - # Upgrade state/output from placeholder scalar -> matrix if needed if self.state["x"] is None: self.state["x"] = np.zeros(self._resolved_shape, dtype=float) else: @@ -200,7 +189,6 @@ def _maybe_freeze_shape_from(self, u: np.ndarray) -> None: scalar = float(x[0, 0]) self.state["x"] = np.full(self._resolved_shape, scalar, dtype=float) - # keep next_state consistent self.next_state["x"] = np.asarray(self.state["x"], dtype=float).copy() y = np.asarray(self.outputs["out"], dtype=float) @@ -208,15 +196,8 @@ def _maybe_freeze_shape_from(self, u: np.ndarray) -> None: scalar = float(y[0, 0]) self.outputs["out"] = np.full(self._resolved_shape, scalar, dtype=float) - # ------------------------------------------------------------------ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray: - """ - Normalize input to 2D array, apply shape policy and scalar broadcasting. - - If u is None: - - return zeros of resolved shape if known, - - else return (1,1) zeros (placeholder), without freezing. - """ + """Normalize input to 2D, applying shape freezing and scalar broadcasting.""" if u is None: if self._resolved_shape is not None: return np.zeros(self._resolved_shape, dtype=float) @@ -228,10 +209,8 @@ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray: f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u_arr.ndim} with shape {u_arr.shape}." ) - # Potentially freeze shape when a non-scalar appears self._maybe_freeze_shape_from(u_arr) - # If shape is frozen and input is scalar -> broadcast if self._resolved_shape is not None: if u_arr.shape == (1, 1) and self._resolved_shape != (1, 1): return np.full(self._resolved_shape, float(u_arr[0, 0]), dtype=float) @@ -243,14 +222,10 @@ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray: return u_arr - # ------------------------------------------------------------------ def _normalize_state(self) -> np.ndarray: - """ - Ensure state exists and matches resolved shape. - """ + """Ensure the state exists and matches the resolved shape.""" x = np.asarray(self.state["x"], dtype=float) - # If shape resolved and x is scalar placeholder -> broadcast if self._resolved_shape is not None and self._resolved_shape != (1, 1): if x.shape == (1, 1): scalar = float(x[0, 0]) diff --git a/pySimBlocks/blocks/operators/gain.py b/pySimBlocks/blocks/operators/gain.py index 0f77faa..6d746e2 100644 --- a/pySimBlocks/blocks/operators/gain.py +++ b/pySimBlocks/blocks/operators/gain.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import re import unicodedata @@ -28,30 +30,15 @@ class Gain(Block): - """ - Static gain block. - - Summary: - Applies a gain to the input signal according to the selected multiplication mode. - - Parameters: - gain: scalar, vector (m,), or matrix (m,n) - Gain coefficient(s). - multiplication: str - One of: - - "Element wise (K * u)" - - "Matrix (K @ u)" - - "Matrix (u @ K)" - sample_time: float, optional - Block execution period. - - Inputs: - in: array (r,c) - Input signal (must be 2D). - - Outputs: - out: array - Output signal (2D), depending on multiplication mode. + """Static gain block. + + Applies a gain to the input signal according to one of three multiplication + modes: element-wise, left matrix product (K @ u), or right matrix product + (u @ K). + + Attributes: + gain: Gain coefficient(s) — scalar float, 1D vector, or 2D matrix. + multiplication: Active multiplication mode string. """ direct_feedthrough = True @@ -68,11 +55,28 @@ def __init__( multiplication: str = MULT_ELEMENTWISE, sample_time: float | None = None, ): + """Initialize a Gain block. + + Args: + name: Unique identifier for this block instance. + gain: Gain coefficient(s). May be a scalar, a 1D vector, or a 2D + matrix. The accepted shape depends on the chosen multiplication + mode. + multiplication: Multiplication mode string. Accepted values include + ``'Element wise (K * u)'``, ``'Matrix (K @ u)'``, and + ``'Matrix (u @ K)'`` as well as common aliases. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + TypeError: If ``multiplication`` is not a string. + ValueError: If ``multiplication`` is not a recognized mode, or if + ``gain`` is not scalar, 1D, or 2D. + """ super().__init__(name, sample_time) self.multiplication = self._parse_multiplication(multiplication) - # Normalize gain if np.isscalar(gain): self.gain = float(gain) self._gain_kind = "scalar" @@ -89,35 +93,34 @@ def __init__( self.inputs["in"] = None self.outputs["out"] = None + # -------------------------------------------------------------------------- # Class methods # -------------------------------------------------------------------------- + @classmethod def _parse_multiplication(cls, multiplication: str) -> str: + """Normalize and validate a multiplication mode string.""" if not isinstance(multiplication, str): raise TypeError(f"[{cls.__name__}] 'multiplication' must be a str.") m = cls._normalize_user_string(multiplication) - # --- Element-wise if m in { "elementwise(k*u)", "elementwise", "elem", "k*u", "*", "k×u", "kxu" }: return cls.MULT_ELEMENTWISE - # --- Left: K @ u if m in { "matrix(k@u)", "k@u", "left", "matleft", "@left" }: return cls.MULT_LEFT - # --- Right: u @ K if m in { "matrix(u@k)", "u@k", "right", "matright", "@right" }: return cls.MULT_RIGHT - # fallback pattern-based (tolère "matrix(...)" etc.) if "k@u" in m: return cls.MULT_LEFT if "u@k" in m: @@ -128,10 +131,17 @@ def _parse_multiplication(cls, multiplication: str) -> str: f"Examples: '{cls.MULT_ELEMENTWISE}', '{cls.MULT_RIGHT}', '{cls.MULT_LEFT}'." ) + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Compute the initial output from the initial input if available. + + Args: + t0: Initial simulation time in seconds. + """ u = self.inputs["in"] if u is None: self.outputs["out"] = None @@ -145,31 +155,48 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = self._compute(u) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Apply the gain to the input and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is not connected. + ValueError: If the input is not 2D or dimensions are incompatible + with the gain and multiplication mode. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is not connected or not set.") self.outputs["out"] = self._compute(u) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: Gain is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + @staticmethod def _normalize_user_string(s: str) -> str: + """Normalize a user-provided string to lowercase ASCII with no spaces.""" s = unicodedata.normalize("NFKC", s) s = s.strip().lower() s = s.replace("\u00A0", " ") s = re.sub(r"\s+", "", s, flags=re.UNICODE) return s - # ------------------------------------------------------------------ def _resolve_initialize(self, u) -> np.ndarray: + """Broadcast a scalar placeholder input to the gain shape at initialization.""" u = u.flatten() if self.multiplication != self.MULT_ELEMENTWISE: u = np.full((self.gain.shape[1], 1), u[0], dtype=float) @@ -180,8 +207,8 @@ def _resolve_initialize(self, u) -> np.ndarray: u = np.full(self.gain.shape, u[0], dtype=float) return u - # ------------------------------------------------------------------ def _compute(self, u) -> np.ndarray: + """Validate input and dispatch to the active multiplication method.""" u = np.asarray(u, dtype=float) if u.ndim != 2: raise ValueError( @@ -197,24 +224,15 @@ def _compute(self, u) -> np.ndarray: if self.multiplication == self.MULT_RIGHT: return self._right_multiply(u) - # Should be impossible due to validation in __init__ raise RuntimeError(f"[{self.name}] Unhandled multiplication mode: {self.multiplication}") - # ------------------------------------------------------------------ def _elementwise(self, u: np.ndarray) -> np.ndarray: - """ - Element-wise multiplication: K * u - - Rules: - - scalar K: y = K * u - - vector K (m,): y = K[:,None] * u, requires u.shape[0] == m - - matrix K (m,n): y = K * u, requires u.shape == (m,n) - """ + """Apply element-wise multiplication K * u.""" if self._gain_kind == "scalar": return self.gain * u if self._gain_kind == "vector": - g = self.gain # shape (m,) + g = self.gain if g.shape[0] != 1 and u.shape[0] != g.shape[0]: raise ValueError( f"[{self.name}] Element-wise mode requires u.shape[0] == len(gain). " @@ -222,7 +240,6 @@ def _elementwise(self, u: np.ndarray) -> np.ndarray: ) return g.reshape(-1, 1) * u - # matrix gain g = self.gain if not self._is_scalar_2d(g) and u.shape != g.shape: raise ValueError( @@ -231,16 +248,8 @@ def _elementwise(self, u: np.ndarray) -> np.ndarray: ) return g * u - # ------------------------------------------------------------------ def _left_multiply(self, u: np.ndarray) -> np.ndarray: - """ - Matrix multiplication: K @ u - - Rules: - - K must be 2D matrix (p,m) - - u must be 2D (m,ncols) - - output is (p,ncols) - """ + """Apply left matrix multiplication K @ u.""" if self._gain_kind != "matrix": raise ValueError( f"[{self.name}] Multiplication mode '{self.MULT_LEFT}' requires a 2D matrix gain. " @@ -256,17 +265,8 @@ def _left_multiply(self, u: np.ndarray) -> np.ndarray: ) return K @ u - # ------------------------------------------------------------------ def _right_multiply(self, u: np.ndarray) -> np.ndarray: - """ - Matrix multiplication: u @ K - - Rules: - - K must be 2D matrix (m,q) - - u must be 2D (nrows,m) - - output is (nrows,q) - """ - + """Apply right matrix multiplication u @ K.""" if self._gain_kind != "matrix": raise ValueError( f"[{self.name}] Multiplication mode '{self.MULT_RIGHT}' requires a 2D matrix gain. " @@ -276,7 +276,6 @@ def _right_multiply(self, u: np.ndarray) -> np.ndarray: K = self.gain m, q = K.shape - # --- Special case: u is a vector (n,1) if u.shape[1] == 1: if u.shape[0] != m: raise ValueError( @@ -285,7 +284,6 @@ def _right_multiply(self, u: np.ndarray) -> np.ndarray: ) return (u.T @ K).T - # --- General case: u is a matrix (nrows,m) if u.shape[1] != m: raise ValueError( f"[{self.name}] Right matrix product requires u.shape[1] == gain.shape[0]. " diff --git a/pySimBlocks/blocks/operators/mux.py b/pySimBlocks/blocks/operators/mux.py index d1b7fd9..e3bf44b 100644 --- a/pySimBlocks/blocks/operators/mux.py +++ b/pySimBlocks/blocks/operators/mux.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,41 +27,31 @@ class Mux(Block): - """ - Vertical signal concatenation block. - - Summary: - Concatenates multiple scalar or column-vector inputs vertically into a - single column vector. - - Parameters: - num_inputs : int - Number of input ports to concatenate. - sample_time : float, optional - Block execution period. - - Inputs: - in1, in2, ..., inN : array - Each input must be either: - - scalar (will be converted to (1,1)) - - 1D array (k,) (will be converted to (k,1)) - - column vector (k,1) - - Any 2D non-column input (k,n) with n != 1 is rejected. - - Outputs: - out : array (sum_k, 1) - Concatenated column vector. - - Notes: - - Stateless. - - Direct feedthrough. - - This block intentionally enforces vector signals (Simulink-like Mux). + """Vertical signal concatenation block. + + Concatenates multiple scalar or column-vector inputs vertically into a + single output column vector. Each input must be a scalar, a 1D array, or + a column vector (n,1). Any 2D non-column input (k,m) with m != 1 is + rejected. + + Attributes: + num_inputs: Number of input ports to concatenate. """ direct_feedthrough = True def __init__(self, name: str, num_inputs: int = 2, sample_time: float | None = None): + """Initialize a Mux block. + + Args: + name: Unique identifier for this block instance. + num_inputs: Number of input ports to concatenate. Must be >= 1. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If ``num_inputs`` is not a positive integer. + """ super().__init__(name, sample_time) if not isinstance(num_inputs, int) or num_inputs < 1: @@ -75,8 +67,13 @@ def __init__(self, name: str, num_inputs: int = 2, sample_time: float | None = N # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: - # If not all inputs available, defer + """Compute the initial output if all inputs are available. + + Args: + t0: Initial simulation time in seconds. + """ for i in range(self.num_inputs): if self.inputs[f"in{i+1}"] is None: self.outputs["out"] = None @@ -84,19 +81,35 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = self._compute_output() - # --------------------------------------------------------- def output_update(self, t: float, dt: float) -> None: + """Concatenate all inputs vertically and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If any input port is not connected. + ValueError: If any input is not a scalar, 1D, or column vector. + """ self.outputs["out"] = self._compute_output() - # --------------------------------------------------------- def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: Mux is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_column_vector(self, input_name: str, value: ArrayLike) -> np.ndarray: + """Convert a scalar, 1D array, or column vector to a (n,1) array.""" arr = np.asarray(value, dtype=float) if arr.ndim == 0: @@ -116,8 +129,8 @@ def _to_column_vector(self, input_name: str, value: ArrayLike) -> np.ndarray: f"Got ndim={arr.ndim} with shape {arr.shape}." ) - # --------------------------------------------------------- def _compute_output(self) -> np.ndarray: + """Collect and concatenate all input column vectors.""" vectors = [] for i in range(self.num_inputs): diff --git a/pySimBlocks/blocks/operators/product.py b/pySimBlocks/blocks/operators/product.py index f75e6d2..271e913 100644 --- a/pySimBlocks/blocks/operators/product.py +++ b/pySimBlocks/blocks/operators/product.py @@ -18,41 +18,30 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from pySimBlocks.core.block import Block class Product(Block): - """ - Multi-input product block. - - Summary: - Computes a product/division of multiple input signals. - - Parameters: - operations : str - String of operators between inputs, using '*' and '/'. - If length is L, number of inputs is L+1. - multiplication : str - "Element-wise (*)" or "Matrix (@)". - sample_time : float, optional - Block execution period. - - Inputs: - in1, in2, ..., inN : array (m,n) - Input signals (must be 2D arrays). - - Outputs: - out : array (p,q) - Product output. - - Notes: - - Stateless block. - - Direct feedthrough. - - Shapes are frozen per input port on first use. - - Element-wise mode supports scalar (1,1) broadcasting only. - - Matrix mode supports '*' only; '/' is rejected. + """Multi-input product block. + + Computes a product or division of multiple 2D input signals. The number of + inputs is ``len(operations) + 1``. Two multiplication modes are supported: + + - **Element-wise**: applies ``*`` and ``/`` component-wise with scalar + (1,1) broadcasting only. + - **Matrix**: applies ``@`` sequentially; division is not supported. + + Input shapes are frozen per port after their first use. + + Attributes: + operations: String of ``'*'`` and ``'/'`` operators, one per adjacent + pair of inputs. + multiplication: Active multiplication mode string. + num_inputs: Total number of input ports. """ direct_feedthrough = True @@ -64,6 +53,23 @@ def __init__( multiplication: str = "Element-wise (*)", sample_time: float | None = None, ): + """Initialize a Product block. + + Args: + name: Unique identifier for this block instance. + operations: String of ``'*'`` and ``'/'`` operators between inputs. + Defaults to ``'*'`` (two inputs, one multiplication). + multiplication: Multiplication mode. Must be ``'Element-wise (*)'`` + or ``'Matrix (@)'``. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + TypeError: If ``operations`` or ``multiplication`` are not strings. + ValueError: If ``operations`` contains unsupported characters, if + ``multiplication`` is not a valid mode, or if ``'/'`` is used + in matrix mode. + """ super().__init__(name, sample_time) if operations is None: @@ -93,40 +99,59 @@ def __init__( self.outputs["out"] = None - # Shape freezing per input port self._input_shapes: dict[str, tuple[int, int]] = {} # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): - # No "fallback" values: missing inputs should be detected normally - # but for init, if any input missing, output stays None + + def initialize(self, t0: float) -> None: + """Compute the initial output if all inputs are available. + + Args: + t0: Initial simulation time in seconds. + """ for i in range(self.num_inputs): if self.inputs[f"in{i+1}"] is None: self.outputs["out"] = None return self.outputs["out"] = self._compute_output() - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): + def output_update(self, t: float, dt: float) -> None: + """Compute the product and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If any input port is not connected. + ValueError: If input shapes are inconsistent or incompatible with + the multiplication mode. + """ self.outputs["out"] = self._compute_output() - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): + def state_update(self, t: float, dt: float) -> None: + """No-op: Product is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ pass # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _get_input_2d(self, port: str) -> np.ndarray: + """Retrieve, validate, and shape-freeze a single input port.""" u = self.inputs[port] if u is None: raise RuntimeError(f"[{self.name}] Input '{port}' is not connected or not set.") - u_arr = self._to_2d_array(port, u) # uses Block helper - # freeze shape per port + u_arr = self._to_2d_array(port, u) if port not in self._input_shapes: self._input_shapes[port] = u_arr.shape elif u_arr.shape != self._input_shapes[port]: @@ -135,19 +160,17 @@ def _get_input_2d(self, port: str) -> np.ndarray: ) return u_arr - # ------------------------------------------------------------------ def _compute_output(self) -> np.ndarray: + """Compute the product of all inputs according to the multiplication mode.""" arrays = [self._get_input_2d(f"in{i+1}") for i in range(self.num_inputs)] if self.multiplication == "Element-wise (*)": - # Only scalar (1,1) broadcasting allowed non_scalar_shapes = {a.shape for a in arrays if not self._is_scalar_2d(a)} if len(non_scalar_shapes) > 1: raise ValueError( f"[{self.name}] Incompatible input shapes for element-wise product: {sorted(non_scalar_shapes)}." ) - # target shape = the unique non-scalar shape if any, else (1,1) target_shape = (1, 1) if len(non_scalar_shapes) == 0 else next(iter(non_scalar_shapes)) def expand(a: np.ndarray) -> np.ndarray: @@ -161,18 +184,15 @@ def expand(a: np.ndarray) -> np.ndarray: for op, a in zip(self.operations, arrays[1:]): if op == "*": result = result * a - else: # "/" + else: result = result / a return result - # -------------------- Matrix (@) mode - # Only '*' allowed by __init__ guard result = arrays[0].astype(float) for a in arrays[1:]: a = a.astype(float) - # scalar scaling cases if self._is_scalar_2d(result) and not self._is_scalar_2d(a): result = float(result[0, 0]) * a continue @@ -183,7 +203,6 @@ def expand(a: np.ndarray) -> np.ndarray: result = np.array([[float(result[0, 0]) * float(a[0, 0])]], dtype=float) continue - # true matrix multiplication if result.shape[1] != a.shape[0]: raise ValueError( f"[{self.name}] Incompatible dimensions for matrix product: " diff --git a/pySimBlocks/blocks/operators/rate_limiter.py b/pySimBlocks/blocks/operators/rate_limiter.py index 16648e9..95e9257 100644 --- a/pySimBlocks/blocks/operators/rate_limiter.py +++ b/pySimBlocks/blocks/operators/rate_limiter.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,39 +27,25 @@ class RateLimiter(Block): - """ - Discrete-time rate limiter block. - - Summary: - Limits the rate of change of the output signal by constraining the - maximum allowed increase and decrease per time step. - - Parameters: - rising_slope : scalar or array-like, optional - Maximum allowed positive rate of change (>= 0). - falling_slope : scalar or array-like, optional - Maximum allowed negative rate of change (<= 0). - initial_output : scalar or array-like, optional - Initial output y(-1). If not provided, y(-1) = u(0). - sample_time : float, optional - Block execution period. - - Inputs: - in : array (m,n) - Input signal (must be 2D). - - Outputs: - out : array (m,n) - Rate-limited output signal. - - Notes: - - Stateful block. - - Direct feedthrough. - - Broadcasting rules (to match input shape (m,n)): - * scalar (1,1) broadcasts to (m,n) - * vector (m,1) broadcasts across columns to (m,n) - * matrix (m,n) must match exactly - - Once resolved, input shape must remain constant. + """Discrete-time rate limiter block. + + Limits the rate of change of the output signal by constraining the maximum + allowed increase and decrease per time step: + + delta = u[k] - y[k-1] + + y[k] = y[k-1] + clip(delta, falling_slope * dt, rising_slope * dt) + + Bounds are applied component-wise and resolved on the first call. Once the + input shape is resolved it must remain constant. + + Attributes: + rising_raw: Raw rising-slope array before broadcasting. + falling_raw: Raw falling-slope array before broadcasting. + rising_slope: Broadcasted rising slope matched to the input shape, or + None before the first resolution. + falling_slope: Broadcasted falling slope matched to the input shape, or + None before the first resolution. """ direct_feedthrough = True @@ -70,28 +58,41 @@ def __init__( initial_output: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a RateLimiter block. + + Args: + name: Unique identifier for this block instance. + rising_slope: Maximum allowed positive rate of change (>= 0). + Accepted shapes: scalar, 1D vector, or 2D matrix. + falling_slope: Maximum allowed negative rate of change (<= 0). + Accepted shapes: scalar, 1D vector, or 2D matrix. + initial_output: Initial output y(-1). If not provided, y(-1) is + set to the first input u(0). + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + ValueError: If ``rising_slope`` has a negative component or + ``falling_slope`` has a positive component. + """ super().__init__(name, sample_time) self.inputs["in"] = None self.outputs["out"] = None - # Raw parameters (2D normalized) self.rising_raw = self._to_2d_array("rising_slope", rising_slope) self.falling_raw = self._to_2d_array("falling_slope", falling_slope) self.y0_raw = None if initial_output is None else self._to_2d_array("initial_output", initial_output) - # Basic sign constraints (raw) if np.any(self.rising_raw < 0): raise ValueError(f"[{self.name}] rising_slope must be >= 0.") if np.any(self.falling_raw > 0): raise ValueError(f"[{self.name}] falling_slope must be <= 0.") - # Resolved parameters (broadcasted to input shape once known) self.rising_slope: ArrayLike | None = None - self.falling_slope: ArrayLike | None = None + self.falling_slope: ArrayLike | None = None self._resolved_shape: tuple[int, int] | None = None - # State self.state["y"] = None self.next_state["y"] = None @@ -99,7 +100,17 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Resolve slopes from the initial input and set the initial state. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None at initialization. + ValueError: If input is not 2D or slopes have incompatible shapes. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.") @@ -120,8 +131,19 @@ def initialize(self, t0: float) -> None: self.state["y"] = y0.copy() self.outputs["out"] = y0.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Apply the rate limit and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None or the block is not + initialized. + ValueError: If input is not 2D or its shape changed after + initialization. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None.") @@ -139,7 +161,6 @@ def output_update(self, t: float, dt: float) -> None: y_prev = self.state["y"] if y_prev.shape != u.shape: - # extra safety: state shape must match input shape raise ValueError( f"[{self.name}] Internal state shape mismatch: y has shape {y_prev.shape}, input has shape {u.shape}." ) @@ -151,21 +172,22 @@ def output_update(self, t: float, dt: float) -> None: du_limited = np.clip(du, du_min, du_max) self.outputs["out"] = y_prev + du_limited - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: + """Store the current output as the previous value for the next step. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self.next_state["y"] = None if self.outputs["out"] is None else self.outputs["out"].copy() # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _broadcast_param(self, p: np.ndarray, target_shape: tuple[int, int], name: str) -> np.ndarray: - """ - Broadcast p to target_shape using explicit rules: - - scalar (1,1) -> (m,n) - - vector (m,1) -> repeat along columns to (m,n) - - matrix (m,n) -> exact match - """ + """Broadcast a parameter array to the target input shape.""" m, n = target_shape if self._is_scalar_2d(p): @@ -184,12 +206,8 @@ def _broadcast_param(self, p: np.ndarray, target_shape: tuple[int, int], name: s f"Allowed: scalar (1,1), vector (m,1), or matrix (m,n)." ) - # ------------------------------------------------------------------ def _resolve_for_input(self, u: np.ndarray) -> None: - """ - Resolve (broadcast) slopes and initial_output to match the current input shape. - Done once. After that, input shape is fixed. - """ + """Broadcast and validate slopes against the input shape on first call.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." @@ -200,7 +218,6 @@ def _resolve_for_input(self, u: np.ndarray) -> None: self.rising_slope = self._broadcast_param(self.rising_raw, u.shape, "rising_slope") self.falling_slope = self._broadcast_param(self.falling_raw, u.shape, "falling_slope") - # Re-check signs after broadcasting (useful if vector/matrix provided) if np.any(self.rising_slope < 0): raise ValueError(f"[{self.name}] rising_slope must be >= 0.") if np.any(self.falling_slope > 0): diff --git a/pySimBlocks/blocks/operators/saturation.py b/pySimBlocks/blocks/operators/saturation.py index cd2b342..40ab3db 100644 --- a/pySimBlocks/blocks/operators/saturation.py +++ b/pySimBlocks/blocks/operators/saturation.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike @@ -25,40 +27,24 @@ class Saturation(Block): - """ - Discrete-time saturation operator. - - Summary: - Applies element-wise saturation to the input signal by enforcing - lower and upper bounds. - - Parameters: - u_min : scalar or array-like, optional - Lower saturation bound. - Accepted: scalar -> (1,1), 1D -> (m,1), 2D -> (m,n). - Broadcasting rules are limited and explicit (see Notes). - u_max : scalar or array-like, optional - Upper saturation bound. - sample_time : float, optional - Block execution period. - - Inputs: - in : array (m,n) - Input signal (must be 2D). - - Outputs: - out : array (m,n) - Saturated output signal. - - Notes: - - Stateless block. - - Direct feedthrough. - - Input must be 2D. No implicit reshape/flatten. - - Broadcasting rules for bounds (to match input shape (m,n)): - * scalar (1,1) broadcasts to (m,n) - * vector (m,1) broadcasts across columns to (m,n) - * matrix (m,n) must match exactly - - Any other shape mismatch raises ValueError. + """Discrete-time saturation operator. + + Applies element-wise saturation to the input signal: + + y = clip(u, u_min, u_max) + + Bounds are resolved component-wise on the first call using explicit + broadcasting rules: scalar (1,1) broadcasts to (m,n); vector (m,1) + broadcasts across columns; matrix (m,n) must match exactly. Once the + input shape is resolved it must remain constant. + + Attributes: + u_min_raw: Raw lower bound before broadcasting. + u_max_raw: Raw upper bound before broadcasting. + u_min: Broadcasted lower bound matched to the input shape, or None + before the first resolution. + u_max: Broadcasted upper bound matched to the input shape, or None + before the first resolution. """ direct_feedthrough = True @@ -70,6 +56,17 @@ def __init__( u_max: ArrayLike = np.inf, sample_time: float | None = None, ): + """Initialize a Saturation block. + + Args: + name: Unique identifier for this block instance. + u_min: Lower saturation bound. Accepted shapes: scalar, 1D vector, + or 2D matrix. + u_max: Upper saturation bound. Accepted shapes: scalar, 1D vector, + or 2D matrix. + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + """ super().__init__(name, sample_time) self.inputs["in"] = None @@ -78,7 +75,6 @@ def __init__( self.u_min_raw = self._to_2d_array("u_min", u_min) self.u_max_raw = self._to_2d_array("u_max", u_max) - # These will become "resolved" (broadcasted) once we know input shape self.u_min = None self.u_max = None self._resolved_shape: tuple[int, int] | None = None @@ -87,7 +83,18 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Resolve bounds from the initial input and compute the initial output. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None at initialization. + ValueError: If input is not 2D, bounds have incompatible shapes, + or ``u_min > u_max`` for any component. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.") @@ -101,8 +108,18 @@ def initialize(self, t0: float) -> None: self._resolve_bounds_for_input(u) self.outputs["out"] = np.clip(u, self.u_min, self.u_max) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Saturate the input and write the result to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None. + ValueError: If input is not 2D or its shape changed after + initialization. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None.") @@ -116,20 +133,22 @@ def output_update(self, t: float, dt: float) -> None: self._resolve_bounds_for_input(u) self.outputs["out"] = np.clip(u, self.u_min, self.u_max) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: Saturation is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _resolve_bounds_for_input(self, u: np.ndarray) -> None: - """ - Resolve (broadcast) u_min/u_max to match the current input shape. - Resolution is done once (first time input is available). After that, - input shape is expected to remain constant. - """ + """Broadcast and validate bounds against the input shape on first call.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." @@ -145,7 +164,6 @@ def _resolve_bounds_for_input(self, u: np.ndarray) -> None: raise ValueError(f"[{self.name}] u_min must be <= u_max for all components.") return - # Already resolved => enforce constant input shape if u.shape != self._resolved_shape: raise ValueError( f"[{self.name}] Input 'in' shape changed after bounds were resolved: " @@ -153,22 +171,17 @@ def _resolve_bounds_for_input(self, u: np.ndarray) -> None: ) def _broadcast_bound(self, b: np.ndarray, target_shape: tuple[int, int], name: str) -> np.ndarray: - """ - Broadcast bound b to target_shape using explicit rules. - """ + """Broadcast a bound array to the target input shape.""" m, n = target_shape - # scalar -> full matrix if self._is_scalar_2d(b): return np.full(target_shape, float(b[0, 0]), dtype=float) - # vector (m,1) -> broadcast across columns if b.ndim == 2 and b.shape[1] == 1 and b.shape[0] == m: if n == 1: return b.astype(float, copy=False) return np.repeat(b.astype(float, copy=False), n, axis=1) - # matrix -> must match exactly if b.shape == target_shape: return b.astype(float, copy=False) diff --git a/pySimBlocks/blocks/operators/sum.py b/pySimBlocks/blocks/operators/sum.py index af90fbc..7cb0663 100644 --- a/pySimBlocks/blocks/operators/sum.py +++ b/pySimBlocks/blocks/operators/sum.py @@ -18,42 +18,23 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from pySimBlocks.core.block import Block class Sum(Block): - """ - Multi-input summation block. - - Summary: - Computes an element-wise sum/subtraction of multiple input signals. - - Parameters: - signs : str - Sequence of '+' and '-' defining the sign of each input (e.g. '++-', '+-'). - If None, defaults to '++' (two inputs). - sample_time : float, optional - Block execution period. - - Inputs: - in1, in2, ..., inN : array (m,n) - Input signals (must be 2D). - Scalar (1,1) inputs can be broadcast to the common target shape. - - Outputs: - out : array (m,n) - Element-wise signed sum. - - Notes: - - Direct feedthrough. - - Stateless. - - Shape policy: - * all inputs must be 2D - * all non-scalar inputs must share exactly the same shape - * scalar (1,1) inputs can be broadcast to that shape - * no other broadcasting is allowed + """Multi-input signed summation block. + + Computes an element-wise signed sum of multiple 2D input signals. All + non-scalar inputs must share the same shape; scalar (1,1) inputs are + broadcast to that shape. + + Attributes: + signs: List of +1.0 or -1.0 coefficients, one per input port. + num_inputs: Number of input ports. """ direct_feedthrough = True @@ -64,6 +45,21 @@ def __init__( signs: str | None = None, sample_time: float | None = None, ): + """Initialize a Sum block. + + Args: + name: Unique identifier for this block instance. + signs: Sequence of ``'+'`` and ``'-'`` defining the sign of each + input (e.g. ``'++-'``, ``'+-'``). Defaults to ``'++'`` (two + positive inputs). + sample_time: Sampling period in seconds, or None to use the global + simulation dt. + + Raises: + TypeError: If ``signs`` is not a string. + ValueError: If ``signs`` is empty or contains characters other than + ``'+'`` and ``'-'``. + """ super().__init__(name, sample_time) if signs is None: @@ -90,10 +86,12 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: - """ - If all inputs are already available, compute output. - Otherwise output stays None until first output_update(). + """Compute the initial output if all inputs are available. + + Args: + t0: Initial simulation time in seconds. """ if any(self.inputs[f"in{i+1}"] is None for i in range(self.num_inputs)): self.outputs["out"] = None @@ -101,9 +99,18 @@ def initialize(self, t0: float) -> None: self.outputs["out"] = self._compute_output() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: - # Validate presence + 2D constraint + """Compute the signed element-wise sum and write it to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If any input port is not connected. + ValueError: If any input is not 2D or non-scalar inputs have + inconsistent shapes. + """ arrays = [] for i in range(self.num_inputs): key = f"in{i+1}" @@ -120,23 +127,22 @@ def output_update(self, t: float, dt: float) -> None: self.outputs["out"] = self._compute_output(prevalidated_arrays=arrays) - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - return # stateless + """No-op: Sum is a stateless block. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ + return # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- - def _resolve_common_shape(self, arrays: list[np.ndarray]) -> tuple[int, int]: - """ - Determine target shape among inputs. - - Scalars (1,1) are broadcastable - - Any non-scalar fixes the target shape - - If multiple non-scalars exist, they must all match exactly - - If all scalars => target is (1,1) - """ + def _resolve_common_shape(self, arrays: list[np.ndarray]) -> tuple[int, int]: + """Determine the target shape from the set of input arrays.""" non_scalar_shapes = {a.shape for a in arrays if not self._is_scalar_2d(a)} if len(non_scalar_shapes) == 0: @@ -150,12 +156,8 @@ def _resolve_common_shape(self, arrays: list[np.ndarray]) -> tuple[int, int]: f"{[a.shape for a in arrays]}. All non-scalar inputs must have the same shape." ) - # ------------------------------------------------------------------ def _broadcast_scalar_only(self, arr: np.ndarray, target_shape: tuple[int, int], input_name: str) -> np.ndarray: - """ - Broadcast only scalar (1,1) to target_shape. - Non-scalar must match target_shape exactly. - """ + """Broadcast scalar (1,1) to target shape; reject non-scalar shape mismatches.""" if self._is_scalar_2d(arr): if target_shape == (1, 1): return arr.astype(float, copy=False) @@ -168,11 +170,8 @@ def _broadcast_scalar_only(self, arr: np.ndarray, target_shape: tuple[int, int], ) return arr.astype(float, copy=False) - # ------------------------------------------------------------------ def _compute_output(self, prevalidated_arrays: list[np.ndarray] | None = None) -> np.ndarray: - """ - Compute signed element-wise sum with strict scalar-only broadcast. - """ + """Compute the signed element-wise sum with scalar-only broadcasting.""" if prevalidated_arrays is None: arrays = [np.asarray(self.inputs[f"in{i+1}"], dtype=float) for i in range(self.num_inputs)] else: diff --git a/pySimBlocks/blocks/operators/zero_order_hold.py b/pySimBlocks/blocks/operators/zero_order_hold.py index 9927e97..7d3e86c 100644 --- a/pySimBlocks/blocks/operators/zero_order_hold.py +++ b/pySimBlocks/blocks/operators/zero_order_hold.py @@ -18,40 +18,36 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from pySimBlocks.core.block import Block class ZeroOrderHold(Block): - """ - Zero-Order Hold (ZOH) block. - - Summary: - Samples the input signal at discrete instants and holds the sampled - value constant between sampling instants. - - Parameters: - sample_time : float - Sampling period Ts (> 0). + """Zero-Order Hold (ZOH) block. - Inputs: - in : array (m,n) - Input signal (must be 2D). + Samples the input at discrete instants separated by ``sample_time`` and + holds the sampled value constant between sampling instants. The input shape + is frozen after the first resolution. - Outputs: - out : array (m,n) - Held output signal. - - Notes: - - Stateful block. - - Direct feedthrough (output depends on current u only at sampling instants). - - Shape is frozen after first resolution. + Attributes: + sample_time: Sampling period in seconds. """ direct_feedthrough = True def __init__(self, name: str, sample_time: float): + """Initialize a ZeroOrderHold block. + + Args: + name: Unique identifier for this block instance. + sample_time: Sampling period Ts (> 0) in seconds. + + Raises: + ValueError: If ``sample_time`` is not a positive number. + """ super().__init__(name, sample_time) if not isinstance(sample_time, (float, int)) or float(sample_time) <= 0.0: @@ -74,7 +70,17 @@ def __init__(self, name: str, sample_time: float): # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): + + def initialize(self, t0: float) -> None: + """Sample the initial input and set up the hold state. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None at initialization. + ValueError: If input is not 2D. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.") @@ -91,8 +97,16 @@ def initialize(self, t0: float): self.outputs["out"] = y0.copy() - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): + def output_update(self, t: float, dt: float) -> None: + """Output the current sample or the held value depending on the elapsed time. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``'in'`` is None or block is not initialized. + """ u = self.inputs["in"] if u is None: raise RuntimeError(f"[{self.name}] Input 'in' is None.") @@ -104,14 +118,21 @@ def output_update(self, t: float, dt: float): if t_last is None: raise RuntimeError(f"[{self.name}] ZOH not initialized (t_last is None).") - # Sample if enough time elapsed if (t - t_last) >= self.sample_time - self.EPS: self.outputs["out"] = u.copy() else: self.outputs["out"] = self.state["y"].copy() - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): + def state_update(self, t: float, dt: float) -> None: + """Update the held value and timestamp if a new sample was taken. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If block is not initialized. + """ t_last = self.state["t_last"] if t_last is None: raise RuntimeError(f"[{self.name}] ZOH not initialized (t_last is None).") @@ -127,7 +148,9 @@ def state_update(self, t: float, dt: float): # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _ensure_shape(self, u: np.ndarray) -> None: + """Validate input shape and freeze it on the first call.""" if u.ndim != 2: raise ValueError( f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}." diff --git a/pySimBlocks/blocks/optimizers/quadratic_program.py b/pySimBlocks/blocks/optimizers/quadratic_program.py index f7d412f..c2d803b 100644 --- a/pySimBlocks/blocks/optimizers/quadratic_program.py +++ b/pySimBlocks/blocks/optimizers/quadratic_program.py @@ -25,59 +25,32 @@ class QuadraticProgram(Block): - """ - General time-varying quadratic program solver. - - Summary: - Solves at each time step the quadratic program: - - minimize 1/2 x^T P x + q^T x - subject to G x <= h - A x = b - lb <= x <= ub - - All problem data are provided as inputs and may vary with time. - Constraints may be omitted by providing None. - - Parameters: - name: str - Block name. - solver: str - QP solver name (default: "clarabel"). - - I/O: - Inputs: - P: array (n,n) - Quadratic cost matrix. - q: array (n,) or (n,1) - Linear cost vector. - G: array (m,n) or None - Inequality constraint matrix. - h: array (m,) or (m,1) or None - Inequality constraint vector. - A: array (p,n) or None - Equality constraint matrix. - b: array (p,) or (p,1) or None - Equality constraint vector. - lb: array (n,) or (n,1) or None - Lower bound on x. (No scalar broadcast.) - ub: array (n,) or (n,1) or None - Upper bound on x. (No scalar broadcast.) - - Outputs: - x: array (n,1) - Optimal solution (or zeros on failure). - status: array (1,1) - Solver status: - 0 = optimal - 1 = infeasible / no solution - 2 = solver error - 3 = input error - cost: array (1,1) - Optimal cost value (NaN on failure). + """General time-varying quadratic program solver block. + + Solves at each time step the quadratic program: + + minimize 1/2 x^T P x + q^T x + + subject to G x <= h, A x = b, lb <= x <= ub + + All problem data are provided as input ports and may vary with time. + Inequality and equality constraints may be omitted by leaving their + input ports unconnected (None). + + Attributes: + solver: Name of the QP solver used to solve the problem. """ def __init__(self, name: str, solver: str = "clarabel"): + """Initialize a QuadraticProgram block. + + Args: + name: Unique identifier for this block instance. + solver: QP solver name. Must be available in ``qpsolvers``. + + Raises: + ValueError: If the requested solver is not available. + """ super().__init__(name) self._size: int | None = None @@ -112,14 +85,27 @@ def __init__(self, name: str, solver: str = "clarabel"): # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): + + def initialize(self, t0: float) -> None: + """Set outputs to default failure values before the first solve. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["x"] = np.zeros((1, 1)) self.outputs["status"] = np.array([[2]]) self.outputs["cost"] = np.array([[np.nan]]) - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): - # --- fetch raw + def output_update(self, t: float, dt: float) -> None: + """Fetch inputs, build the QP, solve it, and write outputs. + + Output ``status`` encodes the result: 0 = optimal, 1 = infeasible, + 2 = solver error, 3 = input error. ``cost`` is NaN on failure. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ P_raw = self.inputs.get("P", None) q_raw = self.inputs.get("q", None) G_raw = self.inputs.get("G", None) @@ -129,7 +115,6 @@ def output_update(self, t: float, dt: float): lb_raw = self.inputs.get("lb", None) ub_raw = self.inputs.get("ub", None) - # --- normalize types/shapes (numpy) try: P = self._as_matrix(P_raw) if P_raw is not None else None q = self._as_vector(q_raw) if q_raw is not None else None @@ -150,10 +135,8 @@ def output_update(self, t: float, dt: float): self._set_failure(status=3) return - # Ensure output placeholder x matches resolved size (even before solving) self._ensure_output_x_size() - # --- build & solve try: problem = Problem(P, q, G, h, A, b, lb, ub) sol = solve_problem(problem, solver=self.solver) @@ -168,7 +151,6 @@ def output_update(self, t: float, dt: float): f"[{self.name}] Solver returned x with shape {x.shape}, expected ({self._size},1)." ) - # cost = 1/2 x^T P x + q^T x cost = 0.5 * float(x.T @ P @ x) + float(q.reshape(1, -1) @ x) self.outputs["x"] = x @@ -178,48 +160,38 @@ def output_update(self, t: float, dt: float): except Exception: self._set_failure(status=2) - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): - pass + def state_update(self, t: float, dt: float) -> None: + """No-op: QuadraticProgram carries no internal state.""" # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + @staticmethod - def _check_needed_input(P, q, G, h, A, b): + def _check_needed_input(P, q, G, h, A, b) -> None: + """Raise if P or q are missing, or if constraint pairs are incomplete.""" if P is None: raise ValueError("Missing required QP input 'P'.") if q is None: raise ValueError("Missing required QP input 'q'.") - # paired constraints if (G is None) != (h is None): raise ValueError("Inequality constraints G and h must both be provided or both be None.") if (A is None) != (b is None): raise ValueError("Equality constraints A and b must both be provided or both be None.") - # ------------------------------------------------------------------ @staticmethod def _as_matrix(value) -> np.ndarray: + """Convert value to a 2D float array; raise if not 2D.""" arr = np.asarray(value, dtype=float) if arr.ndim != 2: raise ValueError(f"Matrix input must be 2D. Got shape {arr.shape}.") return arr - # ------------------------------------------------------------------ @staticmethod def _as_vector(value) -> np.ndarray: - """ - Convert input to a strict 1D vector (n,). - Accepts: - - (n,) -> ok - - (n,1) -> flatten - Rejects: - - scalar - - (1,1) - - any other shape - """ + """Convert value to a strict 1D float array, accepting (n,) or (n,1).""" arr = np.asarray(value, dtype=float) if arr.ndim == 1: @@ -232,12 +204,8 @@ def _as_vector(value) -> np.ndarray: f"Vector input must be shape (n,) or (n,1). Got shape {arr.shape}." ) - # ------------------------------------------------------------------ def _ensure_output_x_size(self) -> None: - """ - Keep outputs['x'] consistent with resolved size if known. - This prevents dimension-propagation issues when the solver fails. - """ + """Keep output ``x`` consistent with the resolved problem size.""" size = self._size if self._size is not None else 1 x = self.outputs.get("x", None) @@ -249,18 +217,31 @@ def _ensure_output_x_size(self) -> None: if x_arr.shape != (size, 1): self.outputs["x"] = np.zeros((size, 1)) - # ------------------------------------------------------------------ - def _set_failure(self, status: int): + def _set_failure(self, status: int) -> None: + """Write a failure status and NaN cost to the outputs.""" self.outputs["status"] = np.array([[status]]) self.outputs["cost"] = np.array([[np.nan]]) self._ensure_output_x_size() - # ------------------------------------------------------------------ - def _check_size_compatibility(self, P, q, G, h, A, b, lb, ub): - # P defines the size n + def _check_size_compatibility(self, P, q, G, h, A, b, lb, ub) -> None: + """Validate that all inputs are dimensionally consistent with P. + + Args: + P: Quadratic cost matrix (n, n). + q: Linear cost vector (n,). + G: Inequality constraint matrix (m, n), or None. + h: Inequality constraint vector (m,), or None. + A: Equality constraint matrix (p, n), or None. + b: Equality constraint vector (p,), or None. + lb: Lower bound vector (n,), or None. + ub: Upper bound vector (n,), or None. + + Raises: + ValueError: If any dimension is inconsistent, or if the problem + size changes across time steps. + """ n = P.shape[0] - # Freeze size once known if self._size is None: self._size = n elif self._size != n: @@ -269,17 +250,14 @@ def _check_size_compatibility(self, P, q, G, h, A, b, lb, ub): f"Previous size: {self._size}, current size: {n}." ) - # P must be square if P.ndim != 2 or P.shape[1] != n: raise ValueError(f"[{self.name}] Input 'P' must be square, got shape {P.shape}.") - # q must be (n,) if q.ndim != 1 or q.shape[0] != n: raise ValueError( f"[{self.name}] Input 'q' has shape {q.shape}. Must be (n,) with n={n}." ) - # Inequality constraints if G is not None: if h is None: raise ValueError(f"[{self.name}] Inequality constraints require both G and h.") @@ -296,7 +274,6 @@ def _check_size_compatibility(self, P, q, G, h, A, b, lb, ub): if h is not None: raise ValueError(f"[{self.name}] Inequality constraints G and h must both be provided or both be None.") - # Equality constraints if A is not None: if b is None: raise ValueError(f"[{self.name}] Equality constraints require both A and b.") @@ -313,7 +290,6 @@ def _check_size_compatibility(self, P, q, G, h, A, b, lb, ub): if b is not None: raise ValueError(f"[{self.name}] Equality constraints A and b must both be provided or both be None.") - # Bounds (no scalar broadcast) if lb is not None: if lb.ndim != 1 or lb.shape[0] != n: raise ValueError( diff --git a/pySimBlocks/blocks/sources/chirp.py b/pySimBlocks/blocks/sources/chirp.py index 1dd01b7..4966771 100644 --- a/pySimBlocks/blocks/sources/chirp.py +++ b/pySimBlocks/blocks/sources/chirp.py @@ -18,18 +18,29 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class Chirp(BlockSource): - """ - Multi-dimensional chirp signal source (linear or logarithmic). - - mode: - "linear" -> linear frequency sweep - "log" -> logarithmic (exponential) sweep + """Multi-dimensional chirp signal source (linear or logarithmic). + + Generates a sinusoidal signal whose frequency sweeps from f0 to f1 + over a given duration, then continues at f1. The sweep can be linear + or logarithmic (exponential). + + Attributes: + amplitude: Amplitude of the chirp signal, as a 2D array. + f0: Starting frequency in Hz, as a 2D array. + f1: Ending frequency in Hz, as a 2D array. + duration: Sweep duration in seconds, as a 2D array. + start_time: Time at which the chirp starts, as a 2D array. + offset: DC offset added to the output, as a 2D array. + phase: Initial phase in radians, as a 2D array. + mode: Frequency sweep mode, either ``"linear"`` or ``"log"``. """ VALID_MODES = {"linear", "log"} @@ -47,6 +58,27 @@ def __init__( mode: str = "linear", sample_time: float | None = None, ): + """Initialize a Chirp block. + + Args: + name: Unique identifier for this block instance. + amplitude: Amplitude of the chirp. Can be scalar, vector, or matrix. + f0: Starting frequency in Hz. Can be scalar, vector, or matrix. + f1: Ending frequency in Hz. Can be scalar, vector, or matrix. + duration: Sweep duration in seconds. Can be scalar, vector, or matrix. + start_time: Time at which the chirp starts in seconds. Can be + scalar, vector, or matrix. + offset: DC offset added to the output. Can be scalar, vector, or matrix. + phase: Initial phase in radians. Can be scalar, vector, or matrix. + mode: Frequency sweep mode. Must be ``"linear"`` or ``"log"``. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If mode is not valid, duration is not strictly positive, + or (in log mode) f0 or f1 are not strictly positive or are equal. + ValueError: If non-scalar parameters have incompatible shapes. + """ super().__init__(name, sample_time) if mode not in self.VALID_MODES: @@ -97,37 +129,55 @@ def __init__( self.outputs["out"] = np.zeros(target_shape, dtype=float) - # ------------------------------------------------------------------ + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- def initialize(self, t0: float) -> None: - self._compute_output(t0) + """Compute and set the output at the initial time t0. - # ------------------------------------------------------------------ + Args: + t0: Initial simulation time in seconds. + """ + self._compute_output(t0) def output_update(self, t: float, dt: float) -> None: + """Compute and write the chirp value to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self._compute_output(t) - # ------------------------------------------------------------------ - def _compute_output(self, t: float) -> None: + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + def _compute_output(self, t: float) -> None: + """Evaluate the chirp formula at time t and write to outputs.""" tau = np.maximum(0.0, t - self.start_time) tau_clip = np.minimum(tau, self.duration) if self.mode == "linear": phi = self._linear_phase(tau, tau_clip) - else: # log phi = self._log_phase(tau, tau_clip) - self.outputs["out"] = ( - self.amplitude * np.sin(phi) + self.offset - ) + self.outputs["out"] = self.amplitude * np.sin(phi) + self.offset - # ------------------------------------------------------------------ + def _linear_phase(self, tau: np.ndarray, tau_clip: np.ndarray) -> np.ndarray: + """Compute the instantaneous phase for a linear frequency sweep. - def _linear_phase(self, tau, tau_clip): + Args: + tau: Elapsed time since start_time, clipped to zero, as a 2D array. + tau_clip: tau clipped to duration, as a 2D array. + Returns: + Instantaneous phase in radians as a 2D array. + """ k = (self.f1 - self.f0) / self.duration phi_sweep = ( @@ -143,10 +193,16 @@ def _linear_phase(self, tau, tau_clip): return phi_sweep + extra + self.phase - # ------------------------------------------------------------------ + def _log_phase(self, tau: np.ndarray, tau_clip: np.ndarray) -> np.ndarray: + """Compute the instantaneous phase for a logarithmic frequency sweep. - def _log_phase(self, tau, tau_clip): + Args: + tau: Elapsed time since start_time, clipped to zero, as a 2D array. + tau_clip: tau clipped to duration, as a 2D array. + Returns: + Instantaneous phase in radians as a 2D array. + """ ratio = self.f1 / self.f0 log_ratio = np.log(ratio) @@ -156,9 +212,6 @@ def _log_phase(self, tau, tau_clip): np.power(ratio, tau_clip / self.duration) - 1.0 ) - # phase continuity after duration - phi_end = coeff * (ratio - 1.0) - extra = ( 2.0 * np.pi * self.f1 * diff --git a/pySimBlocks/blocks/sources/constant.py b/pySimBlocks/blocks/sources/constant.py index dc2ac53..ef796d8 100644 --- a/pySimBlocks/blocks/sources/constant.py +++ b/pySimBlocks/blocks/sources/constant.py @@ -18,37 +18,23 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class Constant(BlockSource): - """ - Constant signal source block. - - Summary: - Generates a constant output signal with a fixed value over time. - The output does not depend on time or any input signal. - - Parameters (overview): - value : float or array-like - Constant output value. Can be scalar, vector, or matrix. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - (none) - Outputs: - out : constant output signal. - - Notes: - - The block has no internal state. - - The output value is held constant for the entire simulation. - - Scalar values are normalized to shape (1,1). - - 1D values are normalized to column vectors (n,1). - - 2D values are preserved as matrices (m,n). + """Constant signal source block. + + Generates a constant output signal with a fixed value over time. + The output does not depend on time or any input signal. + + Attributes: + value: Constant output value as a 2D array. Scalars are normalized + to shape (1,1), 1D arrays to column vectors (n,1), and 2D + arrays are preserved as-is. """ def __init__( @@ -57,9 +43,19 @@ def __init__( value: ArrayLike = 1.0, sample_time: float | None = None, ): + """Initialize a Constant block. + + Args: + name: Unique identifier for this block instance. + value: Constant output value. Can be scalar, vector, or matrix. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + TypeError: If value is not numeric or array-like. + """ super().__init__(name, sample_time) - # Accept numeric scalars and array-like if not isinstance(value, (list, tuple, np.ndarray, float, int)): raise TypeError( f"[{self.name}] Constant 'value' must be numeric or array-like." @@ -74,9 +70,20 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to the constant value at t0. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["out"] = self.value.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Write the constant value to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self.outputs["out"] = self.value.copy() diff --git a/pySimBlocks/blocks/sources/file_source.py b/pySimBlocks/blocks/sources/file_source.py index 1d80da8..c4519ae 100644 --- a/pySimBlocks/blocks/sources/file_source.py +++ b/pySimBlocks/blocks/sources/file_source.py @@ -27,20 +27,23 @@ class FileSource(BlockSource): - """ - Source block that plays samples loaded from a file. - - Supported file types: - - npz: load an array from a key in a .npz archive (key mandatory) - - npy: load an array from a .npy file (no key) - - csv: load one numeric column by name (key=column name) - - Output policy: - - loaded data must be 1D or 2D - - each simulation step emits one row as a column vector - - when the end is reached: - * repeat=True -> restart from first sample - * repeat=False -> output zeros + """Source block that plays samples loaded from a file. + + Supported file formats: ``.npz``, ``.npy``, and ``.csv``. Each simulation + step emits one row of the loaded data as a column vector. When the end of + the data is reached, the block either restarts from the first sample + (``repeat=True``) or outputs zeros. + + 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. + + Attributes: + file_path: Resolved path to the data file as a string. + file_type: Inferred file extension (``"npz"``, ``"npy"``, or ``"csv"``). + key: Array key (NPZ) or column name (CSV) to load. None for NPY files. + repeat: If True, restart from the first sample after the last one. + use_time: If True, select samples by time lookup instead of index. """ VALID_FILE_TYPES = {"npz", "npy", "csv"} @@ -54,6 +57,27 @@ def __init__( use_time: bool = False, sample_time: float | None = None, ): + """Initialize a FileSource block. + + Args: + name: Unique identifier for this block instance. + file_path: Path to the data file. Relative paths are resolved + against the project file directory via ``adapt_params``. + key: Array key for NPZ files or column name for CSV files. + Not used for NPY files. + repeat: If True, loop back to the first sample after the last one. + use_time: If True, select samples by nearest past timestamp + instead of advancing by step index. Requires a ``"time"`` + key or column in the file. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If the file extension is unsupported, if ``use_time`` + is combined with an NPY file or with ``repeat=True``, or if + the loaded data is invalid. + FileNotFoundError: If the file does not exist. + """ super().__init__(name, sample_time) self.file_path = str(file_path) @@ -78,17 +102,26 @@ def __init__( self.outputs["out"] = np.zeros(self._output_shape, dtype=float) + # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod def adapt_params( cls, params: Dict[str, Any], params_dir: Path | None = None, ) -> Dict[str, Any]: - """ - Resolve relative file_path against parameters directory when provided. + """Resolve a relative ``file_path`` against the project directory. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. None if not applicable. + + Returns: + Parameter dict with ``file_path`` resolved to an absolute path. """ adapted = dict(params) file_path = adapted.get("file_path") @@ -104,32 +137,46 @@ def adapt_params( adapted.pop("file_type", None) return adapted + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to the first sample (or time-matched sample) at t0. + + Args: + t0: Initial simulation time in seconds. + """ if self.use_time: self.outputs["out"] = self._current_output_at_time(t0) else: self._index = 0 self.outputs["out"] = self._current_output() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Write the current sample to the output port and advance the index. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ if self.use_time: self.outputs["out"] = self._current_output_at_time(t) else: self.outputs["out"] = self._current_output() self._index += 1 - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: - pass + """No-op: FileSource carries no internal state.""" + # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _load_samples(self) -> np.ndarray: + """Load and validate the data array from the configured file.""" path = Path(self.file_path) if not path.exists(): raise FileNotFoundError(f"[{self.name}] File not found: {path}") @@ -155,8 +202,8 @@ def _load_samples(self) -> np.ndarray: return arr.astype(float, copy=False) - # ------------------------------------------------------------------ def _load_npz(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + """Load an array and optional time vector from an NPZ archive.""" with np.load(path) as data: keys = list(data.files) if len(keys) == 0: @@ -185,16 +232,16 @@ def _load_npz(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: self._validate_time(time, arr.shape[0]) return arr, time - # ------------------------------------------------------------------ def _load_npy(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + """Load an array from a NPY file.""" if self.key not in (None, ""): raise ValueError( f"[{self.name}] key is not used for NPY input." ) return np.asarray(np.load(path), dtype=float), None - # ------------------------------------------------------------------ def _load_csv(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + """Load a column array and optional time vector from a CSV file.""" if not self.key: raise ValueError( f"[{self.name}] key is mandatory for CSV input and must be a column name." @@ -229,8 +276,8 @@ def _load_csv(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: self._validate_time(time, col.shape[0]) return col, time - # ------------------------------------------------------------------ def _to_bool(self, value: bool | str, name: str) -> bool: + """Parse a bool or bool-like string into a Python bool.""" if isinstance(value, bool): return value if isinstance(value, str): @@ -241,8 +288,8 @@ def _to_bool(self, value: bool | str, name: str) -> bool: return False raise ValueError(f"[{self.name}] '{name}' must be a bool.") - # ------------------------------------------------------------------ def _infer_file_type(self, file_path: str) -> str: + """Infer and validate the file type from the file extension.""" ext = Path(file_path).suffix.lower().lstrip(".") if ext not in self.VALID_FILE_TYPES: raise ValueError( @@ -251,8 +298,8 @@ def _infer_file_type(self, file_path: str) -> str: ) return ext - # ------------------------------------------------------------------ def _current_output(self) -> np.ndarray: + """Return the sample at the current index, handling repeat and end-of-data.""" n = self._samples.shape[0] if self._index < n: idx = self._index @@ -264,8 +311,8 @@ def _current_output(self) -> np.ndarray: row = self._samples[idx] return np.asarray(row, dtype=float).reshape(-1, 1) - # ------------------------------------------------------------------ def _current_output_at_time(self, t: float) -> np.ndarray: + """Return the sample corresponding to the nearest past timestamp.""" if self._time is None: raise RuntimeError( f"[{self.name}] Internal error: use_time=True but time data is missing." @@ -278,8 +325,8 @@ def _current_output_at_time(self, t: float) -> np.ndarray: row = self._samples[idx] return np.asarray(row, dtype=float).reshape(-1, 1) - # ------------------------------------------------------------------ def _validate_time(self, time: np.ndarray, n_samples: int) -> None: + """Validate that a time vector is 1D, strictly increasing, and matches n_samples.""" if time.ndim != 1: raise ValueError(f"[{self.name}] time must be a 1D array.") if time.shape[0] != n_samples: diff --git a/pySimBlocks/blocks/sources/function_source.py b/pySimBlocks/blocks/sources/function_source.py index 5262a84..d81977b 100644 --- a/pySimBlocks/blocks/sources/function_source.py +++ b/pySimBlocks/blocks/sources/function_source.py @@ -29,18 +29,19 @@ class FunctionSource(BlockSource): - """ - User-defined source block with no inputs. + """User-defined source block driven by a callable. + + Computes outputs at each step by calling a user-provided function: - Summary: - Computes: - y = f(t, dt) + y = f(t, dt) - Notes: - - The function must accept exactly (t, dt). - - Function must return a dict with keys matching output_keys. - - Each output value can be scalar, 1D, or 2D (internally normalized to 2D). - - Output shape is frozen independently for each output key. + The function must accept exactly ``(t, dt)`` as positional arguments + and return a ``dict`` mapping each key in ``output_keys`` to a scalar, + 1D, or 2D array-like value. Output shapes are frozen after the first + call and must remain constant throughout the simulation. + + Attributes: + output_keys: List of output port names produced by the function. """ def __init__( @@ -50,6 +51,20 @@ def __init__( output_keys: List[str] | None = None, sample_time: float | None = None, ): + """Initialize a FunctionSource block. + + Args: + name: Unique identifier for this block instance. + function: Callable with signature ``f(t, dt) -> dict``. Must + return a dict whose keys match ``output_keys``. + output_keys: List of output port names. Defaults to ``["out"]``. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + TypeError: If ``function`` is None or not callable. + ValueError: If ``output_keys`` is an empty list. + """ super().__init__(name, sample_time) if function is None or not callable(function): @@ -65,17 +80,37 @@ def __init__( k: None for k in self.output_keys } + # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod def adapt_params( cls, params: Dict[str, Any], params_dir: Path | None = None, ) -> Dict[str, Any]: - """ - Adapt YAML parameters by loading a callable from (file_path, function_name). + """Load a callable from ``file_path`` and ``function_name`` YAML keys. + + If ``function`` is already present in ``params``, it is returned as-is. + Otherwise, the callable is loaded dynamically from the specified file. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. None if not applicable. + + Returns: + Parameter dict with ``function`` set to the loaded callable and + ``file_path``/``function_name`` keys removed. + + Raises: + ValueError: If only one of ``file_path`` or ``function_name`` is + provided. + FileNotFoundError: If the function file does not exist. + AttributeError: If the named function is not found in the module. + TypeError: If the resolved attribute is not callable. """ adapted = dict(params) @@ -119,25 +154,40 @@ def adapt_params( adapted["output_keys"] = ["out"] return adapted + # -------------------------------------------------------------------------- - # Public Methods + # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Validate the function signature and compute initial outputs at t0. + + Args: + t0: Initial simulation time in seconds. + """ self._validate_signature() out = self._call_func(t0, 0.0) for key in self.output_keys: self.outputs[key] = out[key] - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Call the user function and write results to the output ports. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ out = self._call_func(t, dt) for key in self.output_keys: self.outputs[key] = out[key] + # -------------------------------------------------------------------------- - # Private Methods + # Private methods # -------------------------------------------------------------------------- + def _call_func(self, t: float, dt: float) -> Dict[str, np.ndarray]: + """Invoke the user function, validate its output, and normalize arrays.""" try: out = self._func(t, dt) except Exception as e: @@ -174,8 +224,8 @@ def _call_func(self, t: float, dt: float) -> Dict[str, np.ndarray]: return normalized - # ------------------------------------------------------------------ def _validate_signature(self) -> None: + """Raise if the user function does not have exactly the signature (t, dt).""" sig = inspect.signature(self._func) params = list(sig.parameters.values()) diff --git a/pySimBlocks/blocks/sources/ramp.py b/pySimBlocks/blocks/sources/ramp.py index 8a52523..c7792c2 100644 --- a/pySimBlocks/blocks/sources/ramp.py +++ b/pySimBlocks/blocks/sources/ramp.py @@ -18,44 +18,28 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class Ramp(BlockSource): - """ - Multi-dimensional ramp signal source block (Option B). - - Summary: - Generates a ramp signal element-wise on a 2D output array: - y(t) = offset + slope * max(0, t - start_time) - - Parameters may be scalars, vectors, or matrices. Only scalar-to-shape - broadcasting is allowed; all non-scalar parameters must share the same - shape. - - Parameters (overview): - slope : float or array-like - Ramp slope. Scalar -> broadcast; otherwise fixes output shape. - start_time : float or array-like, optional - Ramp start time. Scalar -> broadcast; otherwise must match output shape. - offset : float or array-like, optional - Output value before the ramp starts. Scalar -> broadcast; otherwise must match output shape. - sample_time : float, optional - Block execution period. - - Outputs: - out : ramp output signal (2D ndarray) - - Notes: - - Stateless block. - - Normalization: - scalar -> (1,1), 1D -> (n,1), 2D -> (m,n) - - Broadcasting: - Only (1,1) scalars are broadcast to the common shape. - No NumPy broadcasting beyond that. - - No implicit flattening is performed. + """Multi-dimensional ramp signal source block. + + Generates a ramp signal element-wise on a 2D output array: + + y(t) = offset + slope * max(0, t - start_time) + + Parameters may be scalars, vectors, or matrices. Only scalar-to-shape + broadcasting is allowed; all non-scalar parameters must share the same + shape. + + Attributes: + slope: Ramp slope as a 2D array. + start_time: Time at which the ramp starts, as a 2D array. + offset: Output value before the ramp starts, as a 2D array. """ def __init__( @@ -66,6 +50,21 @@ def __init__( offset: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a Ramp block. + + Args: + name: Unique identifier for this block instance. + slope: Ramp slope. Can be scalar, vector, or matrix. + start_time: Time at which the ramp starts in seconds. Can be + scalar, vector, or matrix. + offset: Output value before the ramp starts. Defaults to zero. + Can be scalar, vector, or matrix. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If non-scalar parameters have incompatible shapes. + """ super().__init__(name, sample_time) S = self._to_2d_array("slope", slope, dtype=float) @@ -76,24 +75,33 @@ def __init__( else: O = self._to_2d_array("offset", offset, dtype=float) - # Resolve common shape using strict scalar-only broadcasting policy target_shape = self._resolve_common_shape({"slope": S, "start_time": T, "offset": O}) self.slope = self._broadcast_scalar_only("slope", S, target_shape) self.start_time = self._broadcast_scalar_only("start_time", T, target_shape) self.offset = self._broadcast_scalar_only("offset", O, target_shape) - # Output port self.outputs["out"] = self.offset.copy() + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to the offset value at t0. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["out"] = self.offset.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: - # Element-wise time shift + """Compute and write the ramp value to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ dt_mat = np.maximum(0.0, t - self.start_time) self.outputs["out"] = self.offset + self.slope * dt_mat diff --git a/pySimBlocks/blocks/sources/sinusoidal.py b/pySimBlocks/blocks/sources/sinusoidal.py index efc0cf0..a60ff68 100644 --- a/pySimBlocks/blocks/sources/sinusoidal.py +++ b/pySimBlocks/blocks/sources/sinusoidal.py @@ -18,46 +18,29 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class Sinusoidal(BlockSource): - """ - Multi-dimensional sinusoidal signal source block (Option B). - - Summary: - Generates sinusoidal signals element-wise on a 2D output array: - y(t) = amplitude * sin(2*pi*frequency*t + phase) + offset - - Parameters may be scalars, vectors, or matrices. Only scalar-to-shape - broadcasting is allowed; all non-scalar parameters must share the same - shape. - - Parameters (overview): - amplitude : float or array-like - Sinusoidal amplitude. - frequency : float or array-like - Sinusoidal frequency in Hertz. - phase : float or array-like, optional - Phase shift in radians. - offset : float or array-like, optional - Constant offset added to the signal. - sample_time : float, optional - Block execution period. - - Outputs: - out : sinusoidal output signal (2D ndarray) - - Notes: - - Stateless. - - Normalization: - scalar -> (1,1), 1D -> (n,1), 2D -> (m,n) - - Broadcasting: - Only (1,1) scalars are broadcast to the common shape. - No NumPy broadcasting beyond that. - - No implicit flattening is performed. + """Multi-dimensional sinusoidal signal source block. + + Generates sinusoidal signals element-wise on a 2D output array: + + y(t) = amplitude * sin(2*pi*frequency*t + phase) + offset + + Parameters may be scalars, vectors, or matrices. Only scalar-to-shape + broadcasting is allowed; all non-scalar parameters must share the same + shape. + + Attributes: + amplitude: Sinusoidal amplitude, as a 2D array. + frequency: Frequency in Hz, as a 2D array. + offset: DC offset added to the signal, as a 2D array. + phase: Phase shift in radians, as a 2D array. """ def __init__( @@ -69,6 +52,21 @@ def __init__( phase: ArrayLike = 0.0, sample_time: float | None = None, ): + """Initialize a Sinusoidal block. + + Args: + name: Unique identifier for this block instance. + amplitude: Sinusoidal amplitude. Can be scalar, vector, or matrix. + frequency: Frequency in Hz. Can be scalar, vector, or matrix. + offset: DC offset added to the signal. Can be scalar, vector, + or matrix. + phase: Phase shift in radians. Can be scalar, vector, or matrix. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If non-scalar parameters have incompatible shapes. + """ super().__init__(name, sample_time) A = self._to_2d_array("amplitude", amplitude, dtype=float) @@ -94,18 +92,31 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Compute and set the output at the initial time t0. + + Args: + t0: Initial simulation time in seconds. + """ self._compute_output(t0) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Compute and write the sinusoidal value to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self._compute_output(t) # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _compute_output(self, t: float) -> None: + """Evaluate the sinusoidal formula at time t and write to outputs.""" self.outputs["out"] = ( self.amplitude * np.sin(2.0 * np.pi * self.frequency * t + self.phase) diff --git a/pySimBlocks/blocks/sources/step.py b/pySimBlocks/blocks/sources/step.py index 314a638..004e301 100644 --- a/pySimBlocks/blocks/sources/step.py +++ b/pySimBlocks/blocks/sources/step.py @@ -18,41 +18,25 @@ # Authors: see Authors.txt # ****************************************************************************** -import numpy as np +from __future__ import annotations + from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class Step(BlockSource): - """ - Step signal source block. - - Summary: - Generates a step signal that switches from an initial value to a final - value at a specified time. - - Parameters (overview): - value_before : float or array-like - Output value before the step time. Scalar, vector, or matrix. - value_after : float or array-like - Output value after the step time. Scalar, vector, or matrix. - start_time : float - Time at which the step occurs. - sample_time : float, optional - Block execution period. - - I/O: - Outputs: - out : step output signal. - - Notes: - - The block has no internal state. - - Signals are normalized to 2D arrays: - scalar -> (1,1), 1D -> (n,1), 2D -> (m,n). - - If one value is scalar (1,1) and the other is not, scalar is broadcast - to match the other shape. - - EPS compensates floating-point rounding to ensure consistent behavior - on discrete time grids. + """Step signal source block. + + Generates a step signal that switches from an initial value to a final + value at a specified time. Scalar values are broadcast to match the shape + of non-scalar counterparts. + + Attributes: + value_before: Output value before the step, as a 2D array. + value_after: Output value after the step, as a 2D array. + start_time: Time at which the step occurs in seconds. + EPS: Tolerance used to compensate floating-point rounding on + discrete time grids. """ def __init__( @@ -64,21 +48,37 @@ def __init__( sample_time: float | None = None, eps: float = 1e-12, ): + """Initialize a Step block. + + Args: + name: Unique identifier for this block instance. + value_before: Output value before the step. Can be scalar, + vector, or matrix. + value_after: Output value after the step. Can be scalar, + vector, or matrix. + start_time: Time at which the step occurs in seconds. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + eps: Tolerance for floating-point comparison against start_time. + + Raises: + TypeError: If start_time is not a float or int. + ValueError: If value_before and value_after have incompatible + non-scalar shapes. + """ super().__init__(name, sample_time) vb = self._to_2d_array("value_before", value_before, dtype=float) va = self._to_2d_array("value_after", value_after, dtype=float) - vb, va = self._match_shapes_with_scalar_broadcast(vb, va) + shape = self._resolve_common_shape({"value_before": vb, "value_after": va}) + self.value_before = self._broadcast_scalar_only("value_before", vb, shape) + self.value_after = self._broadcast_scalar_only("value_after", va, shape) - # --- Validate start_time --- if not isinstance(start_time, (float, int)): raise TypeError(f"[{self.name}] start_time must be a float or int.") self.start_time = float(start_time) - self.value_before = vb - self.value_after = va - self.outputs["out"] = None self.EPS = float(eps) @@ -86,53 +86,29 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Set the output to value_before or value_after depending on t0. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["out"] = ( self.value_before.copy() if t0 < self.start_time - self.EPS else self.value_after.copy() ) - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Write value_before or value_after to the output port based on t. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self.outputs["out"] = ( self.value_before.copy() if t < self.start_time - self.EPS else self.value_after.copy() ) - - # -------------------------------------------------------------------------- - # Private methods - # -------------------------------------------------------------------------- - @staticmethod - def _is_scalar_2d(arr: np.ndarray) -> bool: - return arr.shape == (1, 1) - - # ------------------------------------------------------------------ - def _match_shapes_with_scalar_broadcast( - self, - a: np.ndarray, - b: np.ndarray, - ) -> tuple[np.ndarray, np.ndarray]: - """ - If shapes differ: - - allow scalar (1,1) to broadcast to the other's shape - - otherwise raise ValueError - """ - if a.shape == b.shape: - return a, b - - a_is_scalar = self._is_scalar_2d(a) - b_is_scalar = self._is_scalar_2d(b) - - if a_is_scalar and not b_is_scalar: - return np.full(b.shape, float(a[0, 0])), b - - if b_is_scalar and not a_is_scalar: - return a, np.full(a.shape, float(b[0, 0])) - - raise ValueError( - f"[{self.name}] 'value_before' and 'value_after' must have compatible shapes. " - f"Got {a.shape} vs {b.shape}. Only scalar-to-shape broadcasting is allowed." - ) diff --git a/pySimBlocks/blocks/sources/white_noise.py b/pySimBlocks/blocks/sources/white_noise.py index 80b627d..68d00e5 100644 --- a/pySimBlocks/blocks/sources/white_noise.py +++ b/pySimBlocks/blocks/sources/white_noise.py @@ -18,45 +18,29 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike from pySimBlocks.core.block_source import BlockSource class WhiteNoise(BlockSource): - """ - Multi-dimensional Gaussian white noise source block (Option B). - - Summary: - Generates independent Gaussian noise samples at each simulation step, - element-wise on a 2D output array: - y = mean + std * N(0,1) - - Parameters may be scalars, vectors, or matrices. Only scalar-to-shape - broadcasting is allowed; all non-scalar parameters must share the same - shape. - - Parameters (overview): - mean : float or array-like, optional - Mean value of the noise. - std : float or array-like, optional - Standard deviation of the noise (must be >= 0 element-wise). - seed : int, optional - Random seed for reproducibility. - sample_time : float, optional - Block execution period. - - Outputs: - out : noise output signal (2D ndarray) - - Notes: - - Stateless (but uses an internal RNG). - - Normalization: - scalar -> (1,1), 1D -> (n,1), 2D -> (m,n) - - Broadcasting: - Only (1,1) scalars are broadcast to the common shape. - No NumPy broadcasting beyond that. - - No implicit flattening is performed. + """Multi-dimensional Gaussian white noise source block. + + Generates independent Gaussian noise samples at each simulation step, + element-wise on a 2D output array: + + y = mean + std * N(0,1). + + Parameters may be scalars, vectors, or matrices. Only scalar-to-shape + broadcasting is allowed; all non-scalar parameters must share the same + shape. + + Attributes: + mean: Mean value of the noise, as a 2D array. + std: Standard deviation of the noise, as a 2D array. + rng: NumPy random generator instance used to draw samples. """ def __init__( @@ -67,6 +51,21 @@ def __init__( seed: int | None = None, sample_time: float | None = None, ): + """Initialize a WhiteNoise block. + + Args: + name: Unique identifier for this block instance. + mean: Mean value of the noise. Can be scalar, vector, or matrix. + std: Standard deviation of the noise. Can be scalar, vector, + or matrix. Must be >= 0 element-wise. + seed: Random seed for reproducibility. None for a random seed. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If std is negative for any element, or if non-scalar + parameters have incompatible shapes. + """ super().__init__(name, sample_time) M = self._to_2d_array("mean", mean, dtype=float) @@ -80,7 +79,6 @@ def __init__( self.mean = self._broadcast_scalar_only("mean", M, target_shape) self.std = self._broadcast_scalar_only("std", S, target_shape) - # Validate std >= 0 element-wise if np.any(self.std < 0.0): raise ValueError(f"[{self.name}] std must be >= 0 (element-wise).") @@ -92,16 +90,29 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Draw an initial noise sample and set the output. + + Args: + t0: Initial simulation time in seconds. + """ self.outputs["out"] = self._draw() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Draw a new noise sample and write it to the output port. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ self.outputs["out"] = self._draw() # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _draw(self) -> np.ndarray: + """Sample a Gaussian noise array with the configured mean and std.""" return self.mean + self.std * self.rng.standard_normal(self.mean.shape) diff --git a/pySimBlocks/blocks/systems/linear_state_space.py b/pySimBlocks/blocks/systems/linear_state_space.py index 3811387..7a5cd08 100644 --- a/pySimBlocks/blocks/systems/linear_state_space.py +++ b/pySimBlocks/blocks/systems/linear_state_space.py @@ -18,43 +18,29 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike + from pySimBlocks.core.block import Block class LinearStateSpace(Block): - """ - Discrete-time linear state-space system block. - - Summary: - Implements a discrete-time linear state-space system without direct - feedthrough, defined by state and output equations. - - Parameters (overview): - A : array-like - State transition matrix. - B : array-like - Input matrix. - C : array-like - Output matrix. - x0 : array-like, optional - Initial state vector. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - u : input vector. - Outputs: - y : output vector. - x : state vector. - - Notes: - - The system is strictly proper (no direct feedthrough). - - The block has internal state. - - Matrix D is intentionally not supported to avoid algebraic loops. - - The state is updated once per simulation step. + """Discrete-time linear state-space system block. + + Implements a strictly proper discrete-time linear system: + + x[k+1] = A x[k] + B u[k] + + y[k] = C x[k] + + The D matrix is intentionally not supported to avoid algebraic loops. + + Attributes: + A: State transition matrix of shape (n, n). + B: Input matrix of shape (n, m). + C: Output matrix of shape (p, n). """ direct_feedthrough = False @@ -68,13 +54,28 @@ def __init__( x0: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a LinearStateSpace block. + + Args: + name: Unique identifier for this block instance. + A: State transition matrix, array-like of shape (n, n). + B: Input matrix, array-like of shape (n, m). + C: Output matrix, array-like of shape (p, n). + x0: Initial state vector, array-like of shape (n, 1) or (n,). + Defaults to zeros. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If any matrix is not 2D, if dimensions are + inconsistent, or if x0 does not match the state dimension. + """ super().__init__(name, sample_time) self.A = np.asarray(A, dtype=float) self.B = np.asarray(B, dtype=float) self.C = np.asarray(C, dtype=float) - # --- basic matrix checks if self.A.ndim != 2: raise ValueError(f"[{self.name}] A must be 2D. Got shape {self.A.shape}.") if self.B.ndim != 2: @@ -100,13 +101,11 @@ def __init__( self._m = self.B.shape[1] self._p = self.C.shape[0] - # --- initial state x0 if x0 is None: x0_arr = np.zeros((n, 1), dtype=float) else: x0_arr = np.asarray(x0, dtype=float) if x0_arr.ndim == 0: - # scalar x0 is not acceptable for n>1 x0_arr = x0_arr.reshape(1, 1) elif x0_arr.ndim == 1: x0_arr = x0_arr.reshape(-1, 1) @@ -121,7 +120,6 @@ def __init__( self.state["x"] = x0_arr.copy() self.next_state["x"] = x0_arr.copy() - # ports self.inputs["u"] = None self.outputs["y"] = None self.outputs["x"] = None @@ -130,20 +128,40 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Compute initial outputs from the initial state. + + Args: + t0: Initial simulation time in seconds. + """ x = self.state["x"] self.outputs["y"] = self.C @ x self.outputs["x"] = x.copy() self.next_state["x"] = x.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Compute y and x outputs from the committed state. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ x = self.state["x"] self.outputs["y"] = self.C @ x self.outputs["x"] = x.copy() - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: + """Compute the next state x[k+1] = A x[k] + B u[k]. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If input ``u`` is not connected. + ValueError: If input ``u`` has the wrong shape. + """ u = self.inputs["u"] if u is None: raise RuntimeError(f"[{self.name}] Input 'u' is not connected or not set.") @@ -157,7 +175,9 @@ def state_update(self, t: float, dt: float) -> None: # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_col_vec(self, name: str, value: ArrayLike, expected_rows: int) -> np.ndarray: + """Normalize value to a (n,1) column vector and validate its size.""" arr = np.asarray(value, dtype=float) if arr.ndim == 0: diff --git a/pySimBlocks/blocks/systems/non_linear_state_space.py b/pySimBlocks/blocks/systems/non_linear_state_space.py index b2b9d14..fa7a9d6 100644 --- a/pySimBlocks/blocks/systems/non_linear_state_space.py +++ b/pySimBlocks/blocks/systems/non_linear_state_space.py @@ -29,37 +29,22 @@ class NonLinearStateSpace(Block): - """ - User-defined algebraic function block. - - Summary: - Stateless block defined by a user-provided Python function: - x+ = f(t, dt, x, u1, u2, ...) - y = g(t, dt, x) - - Parameters: - state_function : callable - Function to compute next state. - output_function_name : callable - Function to compute outputs. - input_keys : list[str] - Names of input ports. - output_keys : list[str] - Names of output ports. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - Defined dynamically by input_keys. - Outputs: - Defined dynamically by output_keys. - - Notes: - - This block is stateless. - - Ports are fully declarative (V1). - - The function must return a dict with exactly output_keys. - - All inputs and outputs must be numpy arrays of shape (n,1). + """User-defined nonlinear state-space block. + + Implements a nonlinear discrete-time system driven by two user-provided + callables: + + x[k+1] = state_function(t, dt, x, u1, u2, ...) + + y[k] = output_function(t, dt, x) + + Input and output port names are declared dynamically via ``input_keys`` + and ``output_keys``. All inputs and outputs must be column vectors of + shape (n, 1). + + Attributes: + input_keys: Names of the input ports. + output_keys: Names of the output ports. """ direct_feedthrough = False @@ -75,15 +60,33 @@ def __init__( x0: np.ndarray, sample_time: float | None = None, ): + """Initialize a NonLinearStateSpace block. + + Args: + name: Unique identifier for this block instance. + state_function: Callable with signature + ``f(t, dt, x, **inputs) -> np.ndarray`` returning the next + state as a (n, 1) array. + output_function: Callable with signature + ``g(t, dt, x) -> dict`` returning a dict mapping each key + in ``output_keys`` to a (n, 1) array. + input_keys: Names of the input ports. + output_keys: Names of the output ports. + x0: Initial state as a numpy array of shape (n, 1) or (n,). + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + TypeError: If x0 is not a numpy array. + ValueError: If x0 does not have shape (n, 1) or (n,). + """ super().__init__(name=name, sample_time=sample_time) - # ---- parameters self._state_func = state_function self._output_func = output_function self.input_keys = list(input_keys) self.output_keys = list(output_keys) - # ---- initial state if not isinstance(x0, np.ndarray): raise TypeError( f"{self.name}: x0 must be a numpy array" @@ -97,18 +100,34 @@ def __init__( self.state["x"] = x0.copy() self.next_state["x"] = x0.copy() + # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod def adapt_params(cls, params: Dict[str, Any], params_dir: Path | None = None) -> Dict[str, Any]: + """Load state and output callables from ``file_path`` YAML keys. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. Must not be None. + + Returns: + Parameter dict with ``state_function`` and ``output_function`` + set to the loaded callables, and ``file_path``, + ``state_function_name``, ``output_function_name`` keys removed. + + Raises: + ValueError: If ``params_dir`` is None or if required keys are + missing from ``params``. + FileNotFoundError: If the function file does not exist. + AttributeError: If a named function is not found in the module. + TypeError: If a resolved attribute is not callable. """ - Adapt parameters from yaml format to class constructor format. - Adapt function file and name in a yaml format into callable. - """ - # --- 1. Validate required fields if params_dir is None: raise ValueError("parameters_dir must be provided for AlgebraicFunction adapter.") try: @@ -120,7 +139,6 @@ def adapt_params(cls, f"NonLinearStateSpace adapter missing parameter: {e}" ) - # --- 2. Resolve file path (relative to project.yaml directory) path = Path(file_path).expanduser() if not path.is_absolute(): path = (params_dir / path).resolve() @@ -130,13 +148,11 @@ def adapt_params(cls, f"NonLinearStateSpace function file not found: {path}" ) - # --- 3. Load module spec = importlib.util.spec_from_file_location(path.stem, path) module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) - # --- 4. Extract functions try: state_func: Callable = getattr(module, state_func_name) except AttributeError: @@ -161,9 +177,7 @@ def adapt_params(cls, f"'{output_func_name}' in {path} is not callable" ) - # --- 5. Build adapted parameter dict adapted = dict(params) - adapted.pop("file_path", None) adapted.pop("state_function_name", None) adapted.pop("output_function_name", None) @@ -174,44 +188,51 @@ def adapt_params(cls, # -------------------------------------------------------------------------- - # Public Methods + # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): - """ - Load the user function and validate its signature. + + def initialize(self, t0: float) -> None: + """Validate function signatures and declare input/output ports. + + Args: + t0: Initial simulation time in seconds. """ self._validate_signature() - # ---- declare ports for k in self.input_keys: self.inputs[k] = None for k in self.output_keys: self.outputs[k] = None - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): - """ - Compute outputs from current inputs. + def output_update(self, t: float, dt: float) -> None: + """Call the output function and write results to the output ports. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. """ assert self._output_func is not None - # ---- call function x = self.state["x"] out = self._call_output_func(t, dt, x=x) - # ---- assign outputs for k in self.output_keys: self.outputs[k] = out[k] - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): - """ - Compute next state from current inputs. + def state_update(self, t: float, dt: float) -> None: + """Call the state function and store the next state. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + TypeError: If any input value is not a numpy array. + ValueError: If any input does not have shape (n, 1). """ assert self._output_func is not None - # ---- collect inputs kwargs: Dict[str, np.ndarray] = {} for k in self.input_keys: u = self.inputs[k] @@ -225,16 +246,17 @@ def state_update(self, t: float, dt: float): ) kwargs[k] = u - # ---- call function x = self.state["x"] out = self._state_func(t, dt, x=x, **kwargs) self.next_state["x"] = out # -------------------------------------------------------------------------- - # Private Methods + # Private methods # -------------------------------------------------------------------------- - def _call_state_func(self, t, dt, x, **kwargs): + + def _call_state_func(self, t, dt, x, **kwargs) -> np.ndarray: + """Invoke the state function and validate its (n,1) array output.""" try: out = self._state_func(t, dt, x, **kwargs) except Exception as e: @@ -248,8 +270,8 @@ def _call_state_func(self, t, dt, x, **kwargs): return out - # ------------------------------------------------------------------ - def _call_output_func(self, t, dt, x): + def _call_output_func(self, t, dt, x) -> Dict[str, np.ndarray]: + """Invoke the output function and validate its dict output.""" try: out = self._output_func(t, dt, x) except Exception as e: @@ -272,11 +294,8 @@ def _call_output_func(self, t, dt, x): return out - # ------------------------------------------------------------------ - def _validate_signature(self): - """ - Validate function signature against input_keys. - """ + def _validate_signature(self) -> None: + """Raise if state or output functions do not have the expected signature (t, dt, x, ...).""" assert self._state_func is not None assert self._output_func is not None @@ -284,7 +303,6 @@ def _validate_signature(self): sig = inspect.signature(f) params = list(sig.parameters.values()) - # ---- minimum signature: (t, dt, ...) if len(params) < 3: raise ValueError( f"{self.name}: function must have at least arguments (t, dt, x)" @@ -295,7 +313,6 @@ def _validate_signature(self): f"{self.name}: first arguments must be (t, dt, x)" ) - # ---- no *args / **kwargs / defaults for p in params: if p.kind not in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, diff --git a/pySimBlocks/blocks/systems/polytopic_state_space.py b/pySimBlocks/blocks/systems/polytopic_state_space.py index 8439e84..56c03ec 100644 --- a/pySimBlocks/blocks/systems/polytopic_state_space.py +++ b/pySimBlocks/blocks/systems/polytopic_state_space.py @@ -18,43 +18,32 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np from numpy.typing import ArrayLike + from pySimBlocks.core.block import Block class PolytopicStateSpace(Block): - """ - Discrete-time polytopic state-space block. - - Summary: - Implements: - x[k+1] = sum_{i=1}^r w_i[k] (A_i x[k] + B_i u[k]) - y[k] = C x[k] - - Parameters (overview): - A : array-like - concatenation of A_i matrices [A_1, A_2, ..., A_r], shape (nx, r*nx). - B : array-like - concatenation of B_i matrices [B_1, B_2, ..., B_r], shape (nx, r*nu). - C : array-like - Output matrix. - x0 : array-like, optional - Initial state vector. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - u : input vector. - w : vertex weight vector (must sum to 1). - Outputs: - y : output vector. - x : state vector. - - Notes: - - The system is strictly proper (no direct feedthrough). - - The block has internal state. + """Discrete-time polytopic state-space block. + + Implements a convex combination of linear state-space models: + + x[k+1] = sum_{i=1}^r w_i[k] (A_i x[k] + B_i u[k]) + + y[k] = C x[k] + + The weight vector ``w`` must be non-negative and sum to 1 at each step. + Matrices A and B are provided as horizontal concatenations of the + per-vertex matrices: A = [A_1, ..., A_r] of shape (nx, r*nx) and + B = [B_1, ..., B_r] of shape (nx, r*nu). + + Attributes: + A: Concatenated vertex state matrices of shape (nx, r*nx). + B: Concatenated vertex input matrices of shape (nx, r*nu). + C: Output matrix of shape (ny, nx). """ direct_feedthrough = False @@ -68,6 +57,24 @@ def __init__( x0: ArrayLike | None = None, sample_time: float | None = None, ): + """Initialize a PolytopicStateSpace block. + + Args: + name: Unique identifier for this block instance. + A: Concatenated vertex state matrices [A_1, ..., A_r], + array-like of shape (nx, r*nx). + B: Concatenated vertex input matrices [B_1, ..., B_r], + array-like of shape (nx, r*nu). + C: Output matrix, array-like of shape (ny, nx). + x0: Initial state vector, array-like of shape (nx, 1) or (nx,). + Defaults to zeros. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If any matrix is not 2D, if dimensions are + inconsistent, or if x0 does not match the state dimension. + """ super().__init__(name, sample_time) self.A = np.asarray(A, dtype=float) @@ -131,23 +138,45 @@ def __init__( self.outputs["x"] = None self.outputs["y"] = None + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + """Compute initial outputs from the initial state. + + Args: + t0: Initial simulation time in seconds. + """ x = self.state["x"] self.outputs["x"] = x.copy() self.outputs["y"] = self.C @ x self.next_state["x"] = x.copy() - # ------------------------------------------------------------------ def output_update(self, t: float, dt: float) -> None: + """Compute y and x outputs from the committed state. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + """ x = self.state["x"] self.outputs["x"] = x.copy() self.outputs["y"] = self.C @ x - # ------------------------------------------------------------------ def state_update(self, t: float, dt: float) -> None: + """Compute the next state as a weighted sum of vertex dynamics. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If inputs ``w`` or ``u`` are not connected. + ValueError: If ``w`` does not sum to 1, has negative entries, + or if input shapes are wrong. + """ w = self.inputs["w"] u = self.inputs["u"] if w is None: @@ -167,10 +196,13 @@ def state_update(self, t: float, dt: float) -> None: x_next = self.A @ np.kron(w_vec, x) + self.B @ np.kron(w_vec, u_vec) self.next_state["x"] = x_next + # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _to_col_vec(self, name: str, value: ArrayLike, expected_rows: int) -> np.ndarray: + """Normalize value to a (n,1) column vector and validate its size.""" arr = np.asarray(value, dtype=float) if arr.ndim == 0: diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py index ec64d9a..271e289 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_controller.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_controller.py @@ -18,8 +18,11 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path -from typing import Dict, Any, List +from typing import Any, Dict, List + import numpy as np import Sofa @@ -36,46 +39,49 @@ class SofaPysimBlocksController(Sofa.Core.Controller): - """ - Base class for controller to enable Sofa Simulation Loop and pySimBlocks to interact. - - Description: - - SOFA_MASTER: SOFA is the time master. - project_yaml required - pysimblocks time step must be a multiple of sofa time step. - At each pysimblocks step, the controller: - 1) reads measurements from the scene - 2) performs one pySimBlocks step - 3) applies the computed control inputs - - - Non-SOFA_MASTER: pySimBlocks is the time master. - The controller is used ONLY as an I/O shell. - No pySimBlocks model is built or executed. - - Required methods: - - set_inputs(self) - - get_outputs(self) - - Optional methods: - - build_model() : create self.model, self.sofa_block and variables_to_log - - print_logs() : print logs at each step (already implemented) - - save() - - optional parameter: - - variables_to_log: list of signal to log - - Note: - - get_outputs() MUST ALWAYS WORK AND RETURN CONSISTENT SHAPES. + """Base SOFA controller class bridging the SOFA simulation loop and pySimBlocks. + + Supports two operating modes: + + **SOFA_MASTER** (``SOFA_MASTER=True``): SOFA drives the time loop. A + ``project_yaml`` must be provided. At each pySimBlocks step the + controller reads SOFA outputs, runs one pySimBlocks step, and applies + the resulting inputs back to SOFA. + + **pySimBlocks master** (``SOFA_MASTER=False``): pySimBlocks drives the + time loop. The controller acts as a pure I/O shell — no model is built + or executed internally. + + Subclasses must implement :meth:`set_inputs` and :meth:`get_outputs`. + + Attributes: + 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. + sim: The pySimBlocks :class:`Simulator` instance, or None. + step_index: Total number of SOFA animation steps executed. + project_yaml: Path to the pySimBlocks YAML project file. """ - def __init__(self, root: Sofa.Core.Node, name: str ="SofaControllerGui"): + def __init__(self, root: Sofa.Core.Node, 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) self.IS_READY = False self.SOFA_MASTER = True self._imgui = _imgui - # MUST be filled by child controllers self.root = root self.inputs: Dict[str, np.ndarray] = {} self.outputs: Dict[str, np.ndarray] = {} @@ -89,69 +95,58 @@ def __init__(self, root: Sofa.Core.Node, name: str ="SofaControllerGui"): self.project_yaml: str | None = None self._init_failed = False + # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - # --- Mandatory methods. --- - def prepare_scene(self): - """ - Optional initialization hook executed before pySimBlocks starts. - Purpose: - - wait for any preparation condition (fixed number of steps, - convergence, stabilization, etc.), - - optionally record initial measurements or offsets. + def prepare_scene(self) -> None: + """Optional hook executed before the pySimBlocks control loop starts. - The user must set `self.IS_READY = True` when the scene is ready to start - the pySimBlocks control loop. + Override this method to wait for a preparation condition (e.g. a + fixed number of warm-up steps or scene stabilization). Set + ``self.IS_READY = True`` when the scene is ready. The default + implementation sets ``IS_READY`` immediately. """ self.IS_READY = True - # ------------------------------------------------------------------ - def set_inputs(self): - """ - Apply inputs from pySimBlocks to SOFA components. - Must be implemented by child classes. + def set_inputs(self) -> None: + """Apply inputs from pySimBlocks to SOFA components. + + Raises: + NotImplementedError: Always — must be implemented by subclasses. """ raise NotImplementedError("[pySimBlocks] ERROR: set_inputs() must be implemented by subclass.") - # ------------------------------------------------------------------ - def get_outputs(self): - """ - Read state from SOFA components and populate self.outputs. - MUST ALWAYS WORK AND RETURN CONSISTENT SHAPES. - Must be implemented by child classes. + def get_outputs(self) -> None: + """Read state from SOFA components and populate ``self.outputs``. + + Must always succeed and return consistent shapes across calls. + + Raises: + NotImplementedError: Always — must be implemented by subclasses. """ raise NotImplementedError("[pySimBlocks] ERROR: get_outputs() must be implemented by subclass.") - # --- Optionnal methods. --- - def save(self): - """ - Optional: executed at each control step. - Override this method to save logs or export custom data. - The default implementation does nothing. + def save(self) -> None: + """Optional hook executed at each control step. + + Override to save logs or export custom data. The default + implementation does nothing. """ - pass def get_block(self, block_name: str): - """ - Utility method to get a block from the model by name. - Only works if the model has been built. + """Return a block from the pySimBlocks model by name. - Parameters - ---------- - block_name : str - Name of the block to retrieve. + Args: + block_name: Name of the block to retrieve. - Returns - ------- - Block + Returns: The block instance with the specified name. - Raises - ------ - RuntimeError - If the model is not built or if the block is not found. + Raises: + RuntimeError: If the simulator is not initialized or if the + block is not found in the model. """ if self.sim is None: raise RuntimeError("[pySimBlocks] ERROR: Simulator not initialized. Cannot get block.") @@ -159,19 +154,20 @@ def get_block(self, block_name: str): raise RuntimeError(f"[pySimBlocks] ERROR: Block '{block_name}' not found in the model.") return self.sim.model.blocks[block_name] - # ---------------------------------------------------------------------- - # SOFA event callback - # ---------------------------------------------------------------------- - def onAnimateBeginEvent(self, event): - """ - SOFA callback executed before each physical integration step. - - Sequence: - 1. Read SOFA outputs -> get_outputs() - 2. Push them into the exchange block - 3. Advance pySimBlocks one step -> sim.step() - 4. Retrieve controller inputs from exchange block - 5. Apply them to SOFA -> set_inputs() + def onAnimateBeginEvent(self, event) -> None: + """SOFA callback executed before each physical integration step. + + When ``SOFA_MASTER=True``, runs the following sequence at each + pySimBlocks step: + + 1. Read SOFA outputs via :meth:`get_outputs`. + 2. Push them into the exchange block. + 3. Advance pySimBlocks one step. + 4. Retrieve controller inputs from the exchange block. + 5. Apply them to SOFA via :meth:`set_inputs`. + + Args: + event: SOFA animation event (unused). """ if self.SOFA_MASTER: if self._init_failed: @@ -187,8 +183,7 @@ def onAnimateBeginEvent(self, event): self.prepare_scene() if self.IS_READY: - if self.counter % self.ratio ==0: - + if self.counter % self.ratio == 0: self._get_sofa_outputs() self.sim.step() self.sim._log(self.sim_cfg.logging) @@ -207,19 +202,13 @@ def onAnimateBeginEvent(self, event): self.step_index += 1 + # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- - def _build_model(self): - """ - Define the internal pySimBlocks controller model. - Mandatory if purpose to be used in Sofa Simulation (SOFA_MASTER) - Must create: - - self.model - - the exchange block self.sofa_block (SofaExchangeIO) - - self.variables_to_log - """ + def _build_model(self) -> None: + """Load the pySimBlocks model from ``project_yaml``.""" project_path = self.project_yaml if project_path is None: raise RuntimeError("[pySimBlocks] ERROR: SOFA_MASTER=True requires project_yaml to be set.") @@ -229,13 +218,8 @@ def _build_model(self): self.model = Model("sofa_model") build_model_from_dict(self.model, model_dict, params_dir=params_dir) - - # ------------------------------------------------------------------ - def _prepare_pysimblocks(self): - """ - Called once SOFA is initialized AND if SOFA is the master. - Initialize the pysimblock struture. - """ + def _prepare_pysimblocks(self) -> None: + """Initialize the pySimBlocks simulator once SOFA is ready.""" try: if self.SOFA_MASTER and self.project_yaml is None: self._init_failed = True @@ -256,22 +240,18 @@ def _prepare_pysimblocks(self): 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}." - ) + "[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 - # ------------------------------------------------------------------ - def _secure_keys(self): - """ - Ensure that the keys used for SOFA exchange are consistent between the model and the controller. - """ + def _secure_keys(self) -> None: + """Validate that model port keys are a subset of SOFA controller keys.""" 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): @@ -294,24 +274,14 @@ def _secure_keys(self): f"Ensure that the controller in the SOFA block contains at least the same output keys as the SofaExchangeIO block." ) - - # ------------------------------------------------------------------ - def _print_logs(self): - """ - Optional: print selected logged variables at each control step. - Already implemented - """ + def _print_logs(self) -> None: + """Print selected logged variables at the current control step.""" print(f"\nStep: {self.sim_index}") for variable in self.sim_cfg.logging: print(f"{variable}: {self.sim.logs[variable][-1]}") - - # ------------------------------------------------------------------ - def _detect_sofa_exchange_block(self): - """ - Detect the SofaExchangeIO block inside self.model. - Must be called after build_model(). - """ + def _detect_sofa_exchange_block(self) -> None: + """Find the unique SofaExchangeIO block inside the model.""" from pySimBlocks.blocks.systems.sofa.sofa_exchange_i_o import SofaExchangeIO candidates = [blk for blk in self.model.blocks.values() if isinstance(blk, SofaExchangeIO)] @@ -332,29 +302,20 @@ def _detect_sofa_exchange_block(self): self._sofa_block = candidates[0] - # ------------------------------------------------------------------ - def _get_sofa_outputs(self): - """ - Read outputs from SOFA components and push them to pySimBlocks. - """ + def _get_sofa_outputs(self) -> None: + """Read SOFA outputs and push them into the exchange block.""" self.get_outputs() for keys, val in self.outputs.items(): self._sofa_block.outputs[keys] = val - # ------------------------------------------------------------------ - def _set_sofa_inputs(self): - """ - Apply inputs from pySimBlocks to SOFA components. - """ + def _set_sofa_inputs(self) -> None: + """Pull inputs from the exchange block and apply them to SOFA.""" for key, val in self._sofa_block.inputs.items(): self.inputs[key] = val self.set_inputs() - # ------------------------------------------------------------------ - def _set_sofa_plot(self): - """ - Setup ImGui plotting for selected variables. - """ + def _set_sofa_plot(self) -> None: + """Set up ImGui plotting nodes for the configured signals.""" if not self._imgui: return @@ -373,11 +334,8 @@ def _set_sofa_plot(self): self._plot_data[f"{block_name}.{key}"].addData(name=f"value{i}", type="float", value=value[i]) MyGui.PlottingWindow.addData(f"{block_name}.{key}[{i}]", self._plot_data[f"{block_name}.{key}"].getData(f"value{i}")) - # ------------------------------------------------------------------ - def _update_sofa_plot(self): - """ - Update ImGui plotting for selected variables. - """ + def _update_sofa_plot(self) -> None: + """Update ImGui plot values for the configured signals.""" if not self._imgui: return @@ -388,11 +346,8 @@ def _update_sofa_plot(self): for i in range(len(value)): node.getData(f"value{i}").value = float(value[i]) - # ------------------------------------------------------------------ - def _set_sofa_slider(self): - """ - Setup ImGui sliders for selected variables. - """ + def _set_sofa_slider(self) -> None: + """Set up ImGui slider nodes for the configured block attributes.""" if not self._imgui: return @@ -413,15 +368,12 @@ def _set_sofa_slider(self): value = value.flatten() for i in range(len(value)): d = node.addData(name=f"value{i}", type="float", value=value[i]) - MyGui.MyRobotWindow.addSettingInGroup( f"{key}[{i}]", d, extremum[0], extremum[1], f"{block_name}") + MyGui.MyRobotWindow.addSettingInGroup(f"{key}[{i}]", d, extremum[0], extremum[1], f"{block_name}") - # ------------------------------------------------------------------ - def _update_sofa_slider(self): - """ - Update ImGui sliders for selected variables. - """ + def _update_sofa_slider(self) -> None: + """Read ImGui slider values and apply them to the corresponding block attributes.""" if not self._imgui: - return + return for var in self._slider_data: block_name, key = var.split(".") @@ -433,23 +385,18 @@ def _update_sofa_slider(self): new_values.append(node.getData(f"value{i}").value) setattr(block, key, np.array(new_values).reshape(shape)) - # ------------------------------------------------------------------ def _adapt_model_for_sofa(self, model_data: Dict[str, Any]) -> Dict[str, Any]: - """ - Adapt model data for SOFA execution. + """Replace any SofaPlant block with a SofaExchangeIO block. - This replaces any SofaPlant block by a SofaExchangeIO block, - while preserving block name and connections. + Preserves block names and connections so the model topology is + unchanged. Used when SOFA itself runs the simulation and the plant + block is not needed. - Parameters - ---------- - model_data : dict - Model dictionary. + Args: + model_data: Model dictionary loaded from the YAML project file. - Returns - ------- - dict - Adapted model dictionary + Returns: + Adapted model dictionary with SofaPlant replaced by SofaExchangeIO. """ adapted = dict(model_data) adapted_blocks = [] diff --git a/pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py b/pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py index 1f97f39..f9dc8a7 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py @@ -18,60 +18,59 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path from typing import Any, Dict, List + from pySimBlocks.core.block import Block class SofaExchangeIO(Block): - """ - SOFA exchange interface block. - - Summary: - Provides an interface between a pySimBlocks model and an external - SOFA controller by exposing dynamic input and output ports. - - Parameters (overview): - input_keys : list of str - Names of externally provided input signals. - output_keys : list of str - Names of output signals to be consumed by SOFA. - scene_file : str - Path to the SOFA scene file, used only for automatic generation. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - Defined dynamically by input_keys. - Outputs: - Defined dynamically by output_keys. - - Notes: - - This block does not run a SOFA simulation. - - It acts only as a data exchange interface. - - The block is stateless. - - Outputs are produced by the pySimBlocks controller logic. + """SOFA exchange interface block. + + Acts as a data exchange boundary between a pySimBlocks model and an + external SOFA controller. Input and output ports are declared dynamically + from ``input_keys`` and ``output_keys``. The block is stateless and + performs no computation — outputs are produced by upstream blocks in the + pySimBlocks model through normal signal propagation. + + Attributes: + input_keys: Names of the input ports fed by the SOFA controller. + output_keys: Names of the output ports consumed by the SOFA controller. + slider_params: Optional ImGui slider configuration, mapping + ``"BlockName.attr"`` to ``[min, max]`` bounds. """ - direct_feedthrough = False is_source = False - def __init__(self, - name: str, - input_keys: list[str], - output_keys: list[str], - slider_params: Dict[str, List[float]] | None = None, - sample_time: float | None = None - ): + def __init__( + self, + name: str, + input_keys: list[str], + output_keys: list[str], + slider_params: Dict[str, List[float]] | None = None, + sample_time: float | None = None, + ): + """Initialize a SofaExchangeIO block. + + Args: + name: Unique identifier for this block instance. + input_keys: Names of the input ports. + output_keys: Names of the output ports. + slider_params: Optional ImGui slider configuration mapping + ``"BlockName.attr"`` to ``[min, max]`` bounds. None to + disable sliders. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ super().__init__(name, sample_time) self.input_keys = input_keys self.output_keys = output_keys self.slider_params = slider_params - # Declare dynamic ports for k in input_keys: self.inputs[k] = None for k in output_keys: @@ -79,15 +78,23 @@ def __init__(self, # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod - def adapt_params(cls, - params: Dict[str, Any], - params_dir: Path | None = None) -> Dict[str, Any]: - """ - Adapt parameters from yaml format to class constructor format. - Adapt function file and name in a yaml format into callable. + def adapt_params( + cls, + params: Dict[str, Any], + params_dir: Path | None = None, + ) -> Dict[str, Any]: + """Strip the ``scene_file`` key which is not used by this block. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file. Not used here. + + Returns: + Parameter dict with ``scene_file`` removed. """ adapted = dict(params) adapted.pop("scene_file", None) @@ -95,26 +102,25 @@ def adapt_params(cls, # -------------------------------------------------------------------------- - # Public Methods + # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): - pass - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): - """ - Outputs are produced by upstream blocks (controller). - This block itself does nothing but check validity. + def initialize(self, t0: float) -> None: + """No-op: ports are already declared in __init__.""" + + def output_update(self, t: float, dt: float) -> None: + """Verify that all inputs are present; outputs are set by upstream blocks. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. + + Raises: + RuntimeError: If any expected input port is None. """ - # Ensure inputs exist for k in self.input_keys: if self.inputs[k] is None: raise RuntimeError(f"[{self.name}] Missing input '{k}' at time {t}.") - # Outputs are set by other blocks (controller chain) through normal propagation. - pass - - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): - # Stateless block: no internal state - pass + def state_update(self, t: float, dt: float) -> None: + """No-op: SofaExchangeIO carries no internal state.""" diff --git a/pySimBlocks/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/blocks/systems/sofa/sofa_plant.py index 83b8e39..ce1a684 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_plant.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_plant.py @@ -18,14 +18,19 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + +from multiprocessing import Pipe, Process from pathlib import Path -from multiprocessing import Process, Pipe -import numpy as np from typing import Any, Dict, List + +import numpy as np + from pySimBlocks.core.block import Block def sofa_worker(conn, scene_file, input_keys, output_keys): + """Worker function executed in a subprocess to run the SOFA simulation.""" import os import sys import Sofa @@ -35,7 +40,6 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): if scene_dir not in sys.path: sys.path.insert(0, scene_dir) - # 1. Import scene spec = importlib.util.spec_from_file_location("scene", scene_file) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) @@ -82,20 +86,18 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): break Sofa.Simulation.animate(root, dt) - # Send initial outputs try: controller.get_outputs() - initial = {k: np.asarray(controller.outputs[k]).reshape(-1,1) for k in output_keys} + 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}"}) + "cmd": "error", + "message": f"[pySimBlocks] ERROR: Failed to get initial outputs.\n{e}" + }) conn.close() return - # 2. Main loop while True: msg = conn.recv() @@ -108,13 +110,14 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): Sofa.Simulation.animate(root, dt) controller.get_outputs() - outputs = {k: np.asarray(controller.outputs[k]).reshape(-1,1) + 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}"}) + "cmd": "error", + "message": f"[pySimBlocks] ERROR during step execution.\n{e}" + }) break elif msg["cmd"] == "stop": @@ -124,46 +127,47 @@ def sofa_worker(conn, scene_file, input_keys, output_keys): class SofaPlant(Block): - """ - SOFA-based dynamic plant block. - - Summary: - Executes a SOFA simulation as a dynamic system driven by pySimBlocks. - At each control step, inputs are sent to SOFA, the scene advances, - and updated outputs are returned. - - Parameters (overview): - scene_file : str - Path to the SOFA scene file. - input_keys : list[str] - Names of input signals exchanged with SOFA. - output_keys : list[str] - Names of output signals produced by SOFA. - sample_time : float, optional - Block execution period. - - I/O: - Inputs: - Defined dynamically by input_keys. - Outputs: - Defined dynamically by output_keys. - - Notes: - - This block runs SOFA in a separate worker process. - - The block has internal state and no direct feedthrough. + """SOFA-based dynamic plant block. + + Executes a SOFA simulation as a dynamic system driven by pySimBlocks. + SOFA runs in a separate subprocess. At each control step, inputs are sent + to the worker process, the SOFA scene advances by one step, and updated + outputs are returned. + + Attributes: + scene_file: Resolved path to the SOFA scene file. + input_keys: Names of input ports sent to SOFA at each step. + output_keys: Names of output ports received from SOFA at each step. + slider_params: Optional ImGui slider configuration, mapping + ``"BlockName.attr"`` to ``[min, max]`` bounds. """ direct_feedthrough = False need_first = True - def __init__(self, + def __init__( + self, name: str, scene_file: str, input_keys: list[str], output_keys: list[str], slider_params: Dict[str, List[float]] | None = None, - sample_time: float | None = None + sample_time: float | None = None, ): + """Initialize a SofaPlant block. + + Args: + name: Unique identifier for this block instance. + scene_file: Path to the SOFA scene file. Relative paths are + resolved against the project file directory via + ``adapt_params``. + input_keys: Names of input ports to send to SOFA. + output_keys: Names of output ports to receive from SOFA. + slider_params: Optional ImGui slider configuration. None to + disable sliders. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ super().__init__(name, sample_time) self.scene_file = scene_file @@ -184,15 +188,28 @@ def __init__(self, # -------------------------------------------------------------------------- - # Class Methods + # Class methods # -------------------------------------------------------------------------- + @classmethod - def adapt_params(cls, - params: Dict[str, Any], - params_dir: Path | None = None) -> Dict[str, Any]: - """ - Adapt parameters from yaml format to class constructor format. - Adapt function file and name in a yaml format into callable. + def adapt_params( + cls, + params: Dict[str, Any], + params_dir: Path | None = None, + ) -> Dict[str, Any]: + """Resolve a relative ``scene_file`` path against the project directory. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. Must not be None. + + Returns: + Parameter dict with ``scene_file`` resolved to an absolute path. + + Raises: + ValueError: If ``params_dir`` is None or ``scene_file`` is + missing from ``params``. """ if params_dir is None: raise ValueError("params_dir must be provided for SofaPlant adaptation") @@ -212,22 +229,27 @@ def adapt_params(cls, # -------------------------------------------------------------------------- - # Public Methods + # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float): - # Start worker + def initialize(self, t0: float) -> None: + """Start the SOFA worker process and receive initial outputs. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If the SOFA worker reports an error during startup. + """ parent_conn, child_conn = Pipe() self.conn = parent_conn 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.process.start() - # Receive initial outputs initial_outputs = self.conn.recv() if isinstance(initial_outputs, dict) and initial_outputs.get("cmd") == "error": @@ -238,22 +260,27 @@ def initialize(self, t0: float): self.state[k] = initial_outputs[k] self.next_state[k] = initial_outputs[k] + def output_update(self, t: float, dt: float) -> None: + """Forward the committed state outputs to the output ports. - # ------------------------------------------------------------------ - def output_update(self, t: float, dt: float): - """ - Outputs were already updated during the previous state_update(). - This block retrieves outputs from an external SOFA worker process, - so no computation is required here. + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. """ for key in self.output_keys: self.outputs[key] = self.state[key] + def state_update(self, t: float, dt: float) -> None: + """Send inputs to SOFA, advance one step, and store the new outputs. - # ------------------------------------------------------------------ - def state_update(self, t: float, dt: float): + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. - # Send inputs + Raises: + RuntimeError: If any input is missing or the SOFA worker reports + an error. + """ msg = {"cmd": "step", "inputs": {}} for k in self.input_keys: val = self.inputs[k] @@ -265,7 +292,6 @@ def state_update(self, t: float, dt: float): self.conn.send(msg) - # Receive outputs outputs = self.conn.recv() if isinstance(outputs, dict) and outputs.get("cmd") == "error": raise RuntimeError(outputs["message"]) @@ -273,18 +299,16 @@ def state_update(self, t: float, dt: float): for k in self.output_keys: self.next_state[k] = outputs[k] - - # ------------------------------------------------------------------ - def finalize(self): - """Ensure worker process is shutdown cleanly.""" + def finalize(self) -> None: + """Shut down the SOFA worker process cleanly.""" if self.conn: try: self.conn.send({"cmd": "stop"}) - except: + except Exception: pass try: self.conn.close() - except: + except Exception: pass if self.process: @@ -294,13 +318,15 @@ def finalize(self): # -------------------------------------------------------------------------- - # Destructor + # Private methods # -------------------------------------------------------------------------- - def __del__(self): + + def __del__(self) -> None: + """Attempt to stop the worker process on garbage collection.""" if self.conn: try: self.conn.send({"cmd": "stop"}) - except: + except Exception: pass if self.process: self.process.join(timeout=0.5) diff --git a/pySimBlocks/core/block.py b/pySimBlocks/core/block.py index b20be98..7e381fb 100644 --- a/pySimBlocks/core/block.py +++ b/pySimBlocks/core/block.py @@ -26,127 +26,148 @@ class Block(ABC): - """ - Base class for all discrete-time blocks (Simulink-like). - - A block follows two-phase execution: - - 1) output_update(t, dt): - Computes outputs y[k] from: - - current state x[k] - - current inputs u[k] - - 2) state_update(t, dt): - Computes next state x[k+1] from: - - current state x[k] - - current inputs u[k] + """Base class for all discrete-time blocks (Simulink-like). + + A block follows two-phase execution per timestep: + output_update computes y[k] from x[k] and u[k], then state_update + computes x[k+1] from x[k] and u[k]. + + Attributes: + name: Unique identifier for this block instance. + sample_time: Sampling period in seconds, or None to use the global dt. + inputs: Input port values, set by the simulator each step. + outputs: Output port values, written by output_update. + state: Committed state x[k]. + next_state: Pending state x[k+1], written by state_update. """ direct_feedthrough = True + """True if outputs depend directly on inputs.""" + is_source = False + """True if the block produces signals with no inputs.""" def __init__(self, name: str, sample_time: float | None = None): + """Initialize a block. + + Args: + name: Unique identifier for this block instance. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + + Raises: + ValueError: If sample_time is provided but not strictly positive. + """ self.name = name - + if sample_time is not None and sample_time <= 0: raise ValueError(f"[{self.name}] sample_time must be > 0.") self.sample_time = sample_time - - # Dict[str -> np.ndarray] - self.inputs = {} # ports set by the simulator - self.outputs = {} # ports produced at each step - - # Internal states: - # state: x[k] (committed state) - # next_state: x[k+1] (to commit at end of step) - self.state = {} - self.next_state = {} - + + self.inputs: Dict[str, np.ndarray] = {} + self.outputs: Dict[str, np.ndarray] = {} + self.state: Dict[str, np.ndarray] = {} + self.next_state: Dict[str, np.ndarray] = {} + self._effective_sample_time = 0. # -------------------------------------------------------------------------- # Class Methods # -------------------------------------------------------------------------- + @classmethod def adapt_params(cls, params: Dict[str, Any], params_dir: Path | None = None) -> Dict[str, Any]: - """ - Adapt parameters from yaml format to class constructor format. - By default, does nothing. + """Adapt parameters from YAML format to constructor format. + + Args: + params: Raw parameter dict loaded from the YAML project file. + params_dir: Directory of the project file, for resolving relative + paths. None if not applicable. + + Returns: + Parameter dict ready to be passed to the block constructor. """ return params # -------------------------------------------------------------------------- - # Public methods + # Public Methods # -------------------------------------------------------------------------- + @property def has_state(self) -> bool: - """Specify if block is stateful.""" + """True if the block carries internal state.""" return bool(self.state) or bool(self.next_state) - # ------------------------------------------------------------------ @abstractmethod def initialize(self, t0: float): - """ - Initialize internal state x[0] and outputs y[0]. - Must fill: - - self.state[...] (initial state) - - self.outputs[...] (initial outputs) + """Initialize state x[0] and outputs y[0]. + + Must populate self.state and self.outputs before the first step. + + Args: + t0: Initial simulation time in seconds. """ - # ------------------------------------------------------------------ @abstractmethod def output_update(self, t: float, dt: float): - """ - Compute outputs y[k] from x[k] and inputs u[k]. - Called before state_update. - Must write to self.outputs[...]. + """Compute outputs y[k] from x[k] and inputs u[k]. + + Called before state_update each timestep. + Must write to self.outputs. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. """ - # ------------------------------------------------------------------ @abstractmethod def state_update(self, t: float, dt: float): - """ - Compute next state x[k+1] from x[k] and inputs u[k]. - Must write to self.next_state[...]. + """Compute next state x[k+1] from x[k] and inputs u[k]. + + Must write to self.next_state. + + Args: + t: Current simulation time in seconds. + dt: Current time step in seconds. """ - # ------------------------------------------------------------------ def commit_state(self): - """ - Finalize the step by copying x[k+1] into x[k]. - Called by the simulator after all blocks completed state_update(). + """Copy x[k+1] into x[k] to finalize the timestep. + + Called by the simulator after all blocks have completed state_update. """ for key, value in self.next_state.items(): self.state[key] = np.copy(value) - # ------------------------------------------------------------------ def finalize(self): - """ - Optional cleanup method called at the end of the simulation. - """ + """Clean up resources at the end of the simulation.""" # -------------------------------------------------------------------------- - # Private methods + # Private Methods # -------------------------------------------------------------------------- + @staticmethod def _is_scalar_2d(arr: np.ndarray) -> bool: + """True if arr has shape (1, 1).""" return arr.shape == (1, 1) - # ------------------------------------------------------------------ def _to_2d_array(self, param_name: str, value, *, dtype=float) -> np.ndarray: - """ - Normalize into a 2D NumPy array. + """Normalize value to a 2D column-oriented array. + + scalar -> (1,1), 1D (n,) -> (n,1), 2D -> preserved, ndim>2 -> error. + + Args: + param_name: Name of the parameter, used in error messages. + value: Input value to normalize. + dtype: Target NumPy dtype. - Rules: - - scalar -> (1,1) - - 1D -> (n,1) (column vector convention) - - 2D -> preserved as-is (m,n) - - ndim > 2 -> rejected + Raises: + ValueError: If value has more than 2 dimensions. """ arr = np.asarray(value, dtype=dtype) diff --git a/pySimBlocks/core/block_source.py b/pySimBlocks/core/block_source.py index c1f56f9..b6b7bf5 100644 --- a/pySimBlocks/core/block_source.py +++ b/pySimBlocks/core/block_source.py @@ -24,41 +24,54 @@ class BlockSource(Block): - """ - Base class for all source blocks (Constant, Step, Ramp, Sinusoidal, ...). - - Provides: - - normalization utilities for source parameters to produce 2D signals - - strict scalar-only broadcasting to a common 2D shape - - no state update by default + """Base class for all source blocks (Constant, Step, Ramp, Sinusoidal, ...). + + Provides normalization utilities for source parameters to produce 2D + signals, strict scalar-only broadcasting to a common 2D shape, and no + state update by default. """ direct_feedthrough = False is_source = True def __init__(self, name: str, sample_time: float | None = None): + """Initialize a source block. + + Args: + name: Unique identifier for this block instance. + sample_time: Sampling period in seconds, or None to use the + global simulation dt. + """ super().__init__(name, sample_time) # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def state_update(self, t: float, dt: float) -> None: - pass # all sources are stateless + """No-op: all source blocks are stateless.""" # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- - def _resolve_common_shape(self, params: dict[str, np.ndarray]) -> tuple[int, int]: - """ - Determine the common target shape among parameters. - Policy: - - scalars (1,1) are broadcastable - - any non-scalar (2D not (1,1)) fixes the target shape - - if multiple non-scalars exist, all must have exactly the same shape - - if all are scalars -> target shape is (1,1) + def _resolve_common_shape(self, params: dict[str, np.ndarray]) -> tuple[int, int]: + """Determine the common target shape among parameters. + + Scalars (1,1) are broadcastable to any shape. Any non-scalar fixes + the target shape. Multiple non-scalars must all share the same shape. + If all parameters are scalar, returns (1,1). + + Args: + params: Mapping of parameter names to their 2D arrays. + + Returns: + The common (m, n) target shape. + + Raises: + ValueError: If multiple non-scalar parameters have different shapes. """ non_scalar_shapes = {a.shape for a in params.values() if not self._is_scalar_2d(a)} @@ -74,11 +87,22 @@ def _resolve_common_shape(self, params: dict[str, np.ndarray]) -> tuple[int, int f"All non-scalar parameters must have the same (m,n) shape. Got: {details}" ) - # ------------------------------------------------------------------ - def _broadcast_scalar_only(self, param_name: str, arr: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray: - """ - Broadcast only scalar (1,1) to target_shape. - Any non-scalar must already match target_shape exactly. + def _broadcast_scalar_only(self, + param_name: str, + arr: np.ndarray, + target_shape: tuple[int, int]) -> np.ndarray: + """Broadcast a scalar (1,1) array to target_shape; non-scalars must match exactly. + + Args: + param_name: Name of the parameter, used in error messages. + arr: 2D array to broadcast. + target_shape: Target (m, n) shape. + + Returns: + Array of shape target_shape with dtype float. + + Raises: + ValueError: If arr is non-scalar and does not match target_shape. """ if self._is_scalar_2d(arr): if target_shape == (1, 1): diff --git a/pySimBlocks/core/config.py b/pySimBlocks/core/config.py index 6c736e9..b160beb 100644 --- a/pySimBlocks/core/config.py +++ b/pySimBlocks/core/config.py @@ -27,23 +27,37 @@ # --------------------------------------------------------------------- @dataclass(frozen=True) class SimulationConfig: + """Simulation execution configuration. + + Contains only execution-related parameters. Must not hold any + model or block-specific information. """ - Simulation execution configuration. - - This object contains ONLY execution-related parameters. - It must not contain any model or block-specific information. - """ - + + #: Simulation time step in seconds. dt: float + + #: Simulation end time in seconds. T: float + + #: Simulation start time in seconds. t0: float = 0.0 + + #: Integration scheme, either ``"fixed"`` or ``"variable"``. solver: str = "fixed" + + #: Signals to log during simulation. logging: List[str] = field(default_factory=list) - clock: str = "internal" # "internal" or "external" + + #: Clock source, either ``"internal"`` or ``"external"``. + clock: str = "internal" def validate(self) -> None: - """Verify that the configuration is valid. - (ie: dt > 0, T > t0, solver is known) + """Verify that the configuration is consistent. + + Checks that dt > 0, T > t0, solver and clock are known values. + + Raises: + ValueError: If any parameter is invalid or out of range. """ if self.dt <= 0.0: raise ValueError("SimulationConfig.dt must be > 0") @@ -66,18 +80,22 @@ def validate(self) -> None: @dataclass class PlotConfig: - """ - Plot configuration. - + """Plot configuration. + Describes how logged signals should be visualized. - This object contains NO plotting logic. + Contains no plotting logic. """ + #: List of plot descriptors. Each descriptor is a dict with at least a + #: ``"signals"`` field, which is a list of signal names to plot together plots: List[Dict[str, Any]] def validate(self) -> None: - """Verify that the configuration is valid. - (ie: each plot has required fields) + """Verify that each plot descriptor has the required fields. + + Raises: + ValueError: If a plot descriptor is missing ``"signals"`` or + if ``"signals"`` is not a list. """ for i, plot in enumerate(self.plots): if "signals" not in plot: diff --git a/pySimBlocks/core/fixed_time_manager.py b/pySimBlocks/core/fixed_time_manager.py index d9d1aac..4d8ca4f 100644 --- a/pySimBlocks/core/fixed_time_manager.py +++ b/pySimBlocks/core/fixed_time_manager.py @@ -18,20 +18,55 @@ # Authors: see Authors.txt # ****************************************************************************** + class FixedStepTimeManager: - """A time manager for fixed-step simulations. - Handle multiple sample times by ensuring they are multiples of the base time step. + """Time manager for fixed-step simulations. + + Handles multiple sample times by ensuring they are all integer multiples + of the base time step. + + Attributes: + dt: Base time step in seconds. """ + def __init__(self, dt_base: float, sample_times: list[float]): + """Initialize the time manager. + Args: + dt_base: Base simulation time step in seconds. + sample_times: List of block sample times to validate. + + Raises: + ValueError: If dt_base is not strictly positive, or if any + sample time is not an integer multiple of dt_base. + """ if dt_base <= 0: raise ValueError("Base time step must be strictly positive.") self.dt = dt_base self._check_sample_times(sample_times) - def _check_sample_times(self, sample_times): - """Ensure all sample times are multiples of the base time step.""" + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + + def next_dt(self, t: float) -> float: + """Return the next time step. + + Args: + t: Current simulation time in seconds. + + Returns: + The base time step dt (always fixed). + """ + return self.dt + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + + def _check_sample_times(self, sample_times: list[float]) -> None: + """Raise if any sample time is not an integer multiple of dt.""" eps = 1e-12 for st in sample_times: ratio = st / self.dt @@ -40,7 +75,3 @@ def _check_sample_times(self, sample_times): f"In fixed-step mode, sample_time={st} " f"is not a multiple of base dt={self.dt}." ) - - def next_dt(self, t): - """Get the next time step (always fixed).""" - return self.dt diff --git a/pySimBlocks/core/model.py b/pySimBlocks/core/model.py index 8a6b330..eb8e874 100644 --- a/pySimBlocks/core/model.py +++ b/pySimBlocks/core/model.py @@ -30,20 +30,20 @@ class Model: - """ - Discrete-time block-diagram model (Simulink-like). - - Responsibilities: - - Store blocks. - - Store signal connections. - - Build execution order (topological sort). - - Provide fast access to downstream connections. - - Notes: - * Topological sorting is applied only to the combinational graph. - * Blocks with state (i.e., blocks where next_state is non-empty) - are treated as "cycle breakers" (delay elements), - exactly like Simulink does for algebraic loops. + """Discrete-time block-diagram model (Simulink-like). + + Stores blocks and signal connections, builds the topological execution + order, and provides fast access to downstream connections. + + Topological sorting is applied only to the combinational (direct- + feedthrough) graph. Stateful blocks act as cycle breakers, exactly as + Simulink handles algebraic loops (see Simulink PDF p.7). + + Attributes: + name: Identifier for this model. + verbose: If True, print detailed execution-order build logs. + blocks: Registry of blocks keyed by name. + connections: List of signal connections. """ def __init__( @@ -53,6 +53,16 @@ def __init__( params_dir: Path | None = None, verbose: bool = False, ): + """Initialize a model. + + Args: + name: Identifier for this model. + model_data: Optional dict loaded from a YAML project file. + If provided, blocks and connections are built immediately. + params_dir: Directory of the project file, for resolving + relative paths. None if not applicable. + verbose: If True, print execution-order build logs. + """ self.name = name self.verbose = verbose @@ -72,31 +82,58 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- + def add_block(self, block: Block) -> Block: - """Add a block to the model.""" + """Add a block to the model. + + Args: + block: Block instance to register. + + Returns: + The registered block. + + Raises: + ValueError: If a block with the same name already exists. + """ if block.name in self.blocks: raise ValueError(f"Block name '{block.name}' already exists.") self.blocks[block.name] = block return block - # ------------------------------------------------------------------ def get_block_by_name(self, name: str) -> Block: - """Get a block by its name.""" + """Return a block by its name. + + Args: + name: Name of the block to retrieve. + + Returns: + The matching Block instance. + + Raises: + ValueError: If no block with that name exists. + """ if name not in self.blocks: raise ValueError( f"Block name '{name}' not found. Known blocks: {list(self.blocks.keys())}" ) return self.blocks[name] - # ------------------------------------------------------------------ def connect(self, src_block: str, src_port: str, dst_block: str, dst_port: str) -> None: - """ - Connect: - blocks[src_block].outputs[src_port] - to: - blocks[dst_block].inputs[dst_port] + """Connect an output port to an input port. + + Registers a connection from ``blocks[src_block].outputs[src_port]`` + to ``blocks[dst_block].inputs[dst_port]``. + + Args: + src_block: Name of the source block. + src_port: Name of the source output port. + dst_block: Name of the destination block. + dst_port: Name of the destination input port. + + Raises: + ValueError: If src_block or dst_block is not registered. """ if src_block not in self.blocks: raise ValueError( @@ -114,11 +151,18 @@ def connect(self, src_block: str, src_port: str, ) self._connections_dirty = True - # ------------------------------------------------------------------ def build_execution_order(self): - """ - Build Simulink-like execution order based solely on direct-feedthrough - causal dependencies (Simulink PDF p.7). + """Build the Simulink-like output execution order. + + Runs a Kahn topological sort on the direct-feedthrough dependency + graph. Blocks without direct feedthrough act as cycle breakers. + + Returns: + Ordered list of blocks for output_update execution. + + Raises: + RuntimeError: If a direct-feedthrough cycle (algebraic loop) + is detected. """ blocks = self.blocks @@ -200,8 +244,15 @@ def build_execution_order(self): return self._output_execution_order - # ------------------------------------------------------------------ - def downstream_of(self, block_name: str): + def downstream_of(self, block_name: str) -> List[Connection]: + """Return all connections where block_name is the source. + + Args: + block_name: Name of the source block. + + Returns: + List of connections originating from block_name. + """ """ Returns all connections where block_name is the source. """ @@ -209,29 +260,37 @@ def downstream_of(self, block_name: str): self._rebuild_downstream_map() return self._downstream_map.get(block_name, []) - # ------------------------------------------------------------------ - def execution_order(self): - """Get execution order, building it if necessary.""" + def execution_order(self) -> List[Block]: + """Return the output execution order, building it if necessary. + + Returns: + Ordered list of blocks for output_update execution. + """ if not self._output_execution_order: return self.build_execution_order() return self._output_execution_order - # ------------------------------------------------------------------ def predecessors_of(self, block_name): - """Get all blocks that feed into block_name.""" + """Yield the names of all blocks that feed into block_name. + + Args: + block_name: Name of the destination block. + + Yields: + Source block names connected to block_name. + """ for (src, dst) in self.connections: if dst[0] == block_name: yield src[0] - # ------------------------------------------------------------------ - def resolve_sample_times(self, dt): - """ - Resolve effective sample times for all blocks. - - Returns: - has_explicit_rate (bool): True if at least one block defines a sample_time. - """ - + def resolve_sample_times(self, dt) -> None: + """Resolve effective sample times for all blocks. + + Blocks with an explicit sample_time keep it; others inherit dt. + + Args: + dt: Global simulation time step in seconds. + """ for b in self.blocks.values(): if b.sample_time is None: b._effective_sample_time = dt @@ -242,7 +301,9 @@ def resolve_sample_times(self, dt): # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- + def _rebuild_downstream_map(self) -> None: + """Rebuild the downstream connection map from the current connections.""" downstream = {name: [] for name in self.blocks.keys()} for (src, dst) in self.connections: downstream[src[0]].append((src, dst)) diff --git a/pySimBlocks/core/scheduler.py b/pySimBlocks/core/scheduler.py index 786d32f..cb3ff14 100644 --- a/pySimBlocks/core/scheduler.py +++ b/pySimBlocks/core/scheduler.py @@ -20,11 +20,33 @@ from pySimBlocks.core.task import Task + class Scheduler: - """A simple scheduler for managing tasks based on their start times.""" + """Scheduler for dispatching tasks based on their sample times. + + Attributes: + tasks: List of tasks sorted by ascending sample time. + """ + def __init__(self, tasks: list[Task]): + """Initialize the scheduler. + + Args: + tasks: List of tasks to schedule. + """ self.tasks = sorted(tasks, key=lambda t: t.sample_time) - def active_tasks(self, t): - """Return the list of tasks that should run at time t.""" + # -------------------------------------------------------------------------- + # 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. + + Returns: + List of tasks whose should_run(t) returns True. + """ return [task for task in self.tasks if task.should_run(t)] diff --git a/pySimBlocks/core/simulator.py b/pySimBlocks/core/simulator.py index a70c50c..fe45091 100644 --- a/pySimBlocks/core/simulator.py +++ b/pySimBlocks/core/simulator.py @@ -22,6 +22,7 @@ import numpy as np +from pySimBlocks.core.block import Block from pySimBlocks.core.config import SimulationConfig from pySimBlocks.core.fixed_time_manager import FixedStepTimeManager from pySimBlocks.core.model import Model @@ -30,25 +31,24 @@ class Simulator: - """ - Discrete-time simulator with strict Simulink-like semantics: - - For each step k: - - 1) PHASE 1: output_update(t) - Blocks compute outputs y[k] from x[k] and u[k]. - - 2) Propagate all outputs to downstream inputs. - - 3) PHASE 2: state_update(t, dt) - Blocks compute x[k+1] from x[k] and u[k]. - - 4) Commit x[k+1] -> x[k]. - - This guarantees: - - Proper separation of outputs and state transitions. - - Correct causal behavior for feedback loops. - - Algebraic loop detection through the Model's topo ordering. + """Discrete-time simulator with strict Simulink-like semantics. + + Each simulation step follows four phases: + + 1. **output_update** — blocks compute y[k] from x[k] and u[k]. + 2. **Propagate** — outputs are forwarded to downstream inputs. + 3. **state_update** — blocks compute x[k+1] from x[k] and u[k]. + 4. **Commit** — x[k+1] is copied into x[k]. + + This guarantees proper separation of outputs and state transitions, + correct causal behavior for feedback loops, and algebraic loop + detection through the model's topological ordering. + + Attributes: + model: The block-diagram model to simulate. + sim_cfg: Simulation execution configuration. + verbose: If True, print step-by-step execution logs. + logs: Logged signal values keyed by variable name. """ def __init__( @@ -57,7 +57,13 @@ def __init__( sim_cfg: SimulationConfig, verbose: bool = False, ): - + """Initialize and compile the simulator. + + Args: + model: The block-diagram model to simulate. + sim_cfg: Simulation execution configuration. + verbose: If True, print execution logs. + """ self.model = model self.sim_cfg = sim_cfg self.verbose = verbose @@ -72,14 +78,21 @@ def __init__( # -------------------------------------------------------------------------- # Public methods # -------------------------------------------------------------------------- - def initialize(self, t0: float = 0.0): - """Initialize all blocks and propagate initial outputs.""" + + def initialize(self, t0: float = 0.0) -> None: + """Initialize all blocks and propagate initial outputs. + + Args: + t0: Initial simulation time in seconds. + + Raises: + RuntimeError: If any block raises during initialization. + """ self.t = float(t0) self.t_step = float(t0) self.logs = {"time": []} self._log_shapes: Dict[str, tuple[int, int]] = {} - # Initialisation bloc par bloc + propagation for block in self.output_order: try: block.initialize(self.t) @@ -91,14 +104,20 @@ def initialize(self, t0: float = 0.0): for task in self.tasks: task.update_state_blocks() - # ------------------------------------------------------------------ def step(self, dt_override: float | None = None) -> None: - """ - Perform one simulation step. - - If dt_override is provided, the simulator time advance is driven by this - external dt (real-time clock). Otherwise, dt is provided by the internal - time manager (fixed-step). + """Perform one simulation step. + + With an internal clock, dt is provided by the time manager. + With an external clock, dt_override must be supplied by the caller. + + Args: + dt_override: Time step in seconds, required when using an + external clock. Must not be provided for an internal clock. + + Raises: + RuntimeError: If dt_override is missing for an external clock, + or provided for an internal clock. + ValueError: If dt_override is not strictly positive. """ # 0) Choose dt for this tick if self.sim_cfg.clock == "external": @@ -132,6 +151,7 @@ def step(self, dt_override: float | None = None) -> None: for block in task.state_blocks: block.state_update(self.t, dt_task) + # PHASE 3 — commit states for task in active_tasks: for block in task.state_blocks: block.commit_state() @@ -142,17 +162,27 @@ def step(self, dt_override: float | None = None) -> None: self.t_step = self.t self.t += dt_scheduler - # ------------------------------------------------------------------ def run( self, T: float | None = None, t0: float | None = None, logging: list[str] | None = None, - ): + ) -> Dict[str, List[np.ndarray]]: """Run the simulation from t0 to T. - If T, t0 or logging are not provided, use the simulator's config. + + Falls back to sim_cfg values for any argument not provided. + + Args: + T: Simulation end time in seconds. + t0: Simulation start time in seconds. + logging: List of variable names to log (e.g. + ``"BlockName.outputs.port"``). + Returns: - logs (Dict[str, List[np.ndarray]]): Logged variables over time. + Dict mapping variable names to their logged values over time. + + Raises: + RuntimeError: If called with an external clock configuration. """ if self.sim_cfg.clock == "external": raise RuntimeError("Simulator.run() is not supported with external clock. Use step(dt_override=...)") @@ -163,7 +193,6 @@ def run( self.initialize(t0_run) - # Main loop (with a small epsilon to avoid floating-point issues) eps = 1e-12 while self.t_step < sim_duration - eps: self.step() @@ -174,26 +203,38 @@ def run( for variable in logging_run: print(f"{variable}: {self.logs[variable][-1]}") - - for block in self.model.blocks.values(): try: block.finalize() except Exception as e: print(f"[WARNING] finalize() failed for block {block.name}: {e}") - return self.logs - # ------------------------------------------------------------------ def get_data(self, variable: str | None = None, block:str | None = None, port: str | None = None) -> np.ndarray: - """Retrieve logged data for a specific variable, block output, or state. - Provide either: - - variable: the full variable name as logged (e.g., "BlockName.outputs.Port) - - block and port: to specify an output variable (e.g., block="BlockName", port="Port") + """Retrieve logged data for a variable as a NumPy array. + + Provide either variable or the (block, port) pair: + + - ``variable``: full log key, e.g. ``"BlockName.outputs.port"``. + - ``block`` + ``port``: shorthand for ``"block.outputs.port"``. + + Args: + variable: Full variable name as logged. + block: Block name (used with port). + port: Output port name (used with block). + + Returns: + Array of shape ``(n_steps, *signal_shape)`` containing the + logged values. + + Raises: + ValueError: If neither variable nor (block, port) is provided, + if the variable is not found in logs, or if the log is empty + or cannot be converted to a NumPy array. """ if variable is not None: var_name = variable @@ -222,18 +263,22 @@ def get_data(self, # -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- - def _compile(self): + + def _compile(self) -> None: """Prepare the simulator for execution. - - Build execution order. - - Group blocks into tasks by sample time. - - Initialize the scheduler and time manager. + + Builds execution order, groups blocks into tasks by sample time, + and initializes the scheduler and time manager. + + Raises: + NotImplementedError: If solver is ``"variable"``. + ValueError: If solver is unknown. """ self.output_order = self.model.build_execution_order() self.model.resolve_sample_times(self.sim_cfg.dt) self.model._rebuild_downstream_map() sample_times = [b._effective_sample_time for b in self.model.blocks.values()] - # regroup blocks by sample time tasks_by_ts = {} for b in self.model.blocks.values(): sample_time = b._effective_sample_time @@ -265,12 +310,8 @@ def _compile(self): "Supported modes are: 'fixed', 'variable'." ) - - # ------------------------------------------------------------------ - def _propagate_from(self, block): - """ - Propagate outputs of `block` to its direct downstream blocks. - """ + def _propagate_from(self, block: Block) -> None: + """Forward outputs of block to its direct downstream inputs.""" blocks = self.model.blocks for (src, dst) in self.model.downstream_of(block.name): _, src_port = src @@ -279,13 +320,14 @@ def _propagate_from(self, block): if value is not None: blocks[dst_block].inputs[dst_port] = value - # ------------------------------------------------------------------ - def _log(self, variables_to_log): - """Log specified variables at the current time step. - - Enforces: - - logged values must be 2D numpy arrays - - shape must stay constant over time for each logged variable + def _log(self, variables_to_log: List[str]) -> None: + """Log specified variables at the current timestep. + + Raises: + ValueError: If a variable format is invalid or the container + is unknown. + RuntimeError: If a logged value is None, not 2D, or changes + shape across timesteps. """ for var in variables_to_log: block_name, container, key = var.split(".") @@ -311,7 +353,6 @@ def _log(self, variables_to_log): f"got ndim={arr.ndim} with shape {arr.shape}." ) - # Enforce constant shape over time for this variable if var not in self._log_shapes: self._log_shapes[var] = arr.shape else: diff --git a/pySimBlocks/core/task.py b/pySimBlocks/core/task.py index 1ee01a7..07f3748 100644 --- a/pySimBlocks/core/task.py +++ b/pySimBlocks/core/task.py @@ -18,9 +18,39 @@ # Authors: see Authors.txt # ****************************************************************************** +from typing import List + +from pySimBlocks.core.block import Block + + class Task: - """A task represents a group of blocks that share the same sample time.""" - def __init__(self, sample_time, blocks, global_output_order): + """A group of blocks sharing the same sample time. + + Manages the scheduling and execution of output updates, state updates, + and state commits for all blocks in the group. + + 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. + 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]): + """Initialize a task. + + Args: + sample_time: Sampling period in seconds. + blocks: Set of blocks belonging to this task. + global_output_order: Global topological order of all blocks, + used to filter and preserve execution order within the task. + """ self.sample_time = sample_time self.next_activation = 0.0 self.last_activation = None @@ -31,43 +61,45 @@ def __init__(self, sample_time, blocks, global_output_order): ] self.state_blocks = [] - def update_state_blocks(self): - """Update the list of blocks with state within this task.""" + # -------------------------------------------------------------------------- + # 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 ] - def should_run(self, t, eps=1e-12): - """Check if the task should run at time t.""" + def should_run(self, t: float, eps: float = 1e-12) -> 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). + """ return t + eps >= self.next_activation - def get_dt(self, t): - """Get the time step for this task at time t.""" + 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. + """ if self.last_activation is None: return self.sample_time return t - self.last_activation - def advance(self): - """Advance the task's activation times.""" + def advance(self) -> None: + """Advance activation timestamps by one sample period.""" self.last_activation = self.next_activation self.next_activation += self.sample_time - - def run_outputs(self, t: float, dt: float, propagate_cb): - for block in self.output_blocks: - block.output_update(t, dt) - propagate_cb(block) - - def run_states(self, t: float, dt: float): - for block in self.state_blocks: - block.state_update(t, dt) - - def commit_states(self): - for block in self.state_blocks: - block.commit_state() - - def tick(self, t: float, dt: float, propagate_cb): - # Optionnel : si tu veux une API unique, mais attention : - # tu ne peux PAS commit ici si tu veux respecter le "all state_update before any commit" - self.run_outputs(t, dt, propagate_cb) - self.run_states(t, dt) diff --git a/pySimBlocks/gui/addons/sofa/sofa_dialog.py b/pySimBlocks/gui/addons/sofa/sofa_dialog.py index 76e2111..b567a2b 100644 --- a/pySimBlocks/gui/addons/sofa/sofa_dialog.py +++ b/pySimBlocks/gui/addons/sofa/sofa_dialog.py @@ -36,7 +36,22 @@ class SofaDialog(QDialog): + """Configure and launch SOFA integration actions from the GUI. + + Attributes: + sofa_service: Service handling SOFA detection, export, and execution. + """ + def __init__(self, sofa_service: SofaService, parent=None): + """Initialize the SOFA dialog. + + Args: + sofa_service: Service handling SOFA integration. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle("Edit block") self.setMinimumWidth(300) @@ -61,9 +76,16 @@ def __init__(self, sofa_service: SofaService, parent=None): buttons_layout.addWidget(apply_btn) main_layout.addLayout(buttons_layout) - # ------------------------------------------------------------ - # Form + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def build_form(self, layout): + """Build the SOFA configuration form. + + Args: + layout: Parent layout receiving the form. + """ form = QFormLayout() # --- Block name --- @@ -93,9 +115,12 @@ def build_form(self, layout): layout.addLayout(form) - # ------------------------------------------------------------ - # Buttons def apply(self): + """Validate and apply the SOFA executable path. + + Returns: + True if the SOFA path is valid, otherwise False. + """ sofa_path = self.run_edit.text() if not Path(sofa_path).exists(): QMessageBox.warning( @@ -108,11 +133,13 @@ def apply(self): return True def ok(self): + """Apply the current values and close the dialog.""" if not self.apply(): return self.accept() def run(self): + """Run the current SOFA scene through the configured service.""" if not self.apply(): return if not self._update_scene_file(): @@ -145,6 +172,7 @@ def run(self): dialog.exec() def export(self): + """Export the SOFA controller for the current project.""" if not self.apply(): return if not self._update_scene_file(): @@ -152,12 +180,16 @@ def export(self): window = self.parent() self.sofa_service.export_controller(window, window.saver) - # ------------------------------------------------------------ - # internal methods + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _on_gui_changed(self, value): + """Update the selected SOFA GUI backend.""" self.sofa_service.gui = value def _update_scene_file(self): + """Validate and cache the scene file through the SOFA service.""" ok, msg, details = self.sofa_service.get_scene_file() if not ok: QMessageBox.warning( @@ -171,7 +203,23 @@ def _update_scene_file(self): class LogDialog(QDialog): + """Display execution logs in a read-only dialog. + + Attributes: + text: Read-only text area showing the log content. + """ + def __init__(self, title: str, content: str, parent=None): + """Initialize the log dialog. + + Args: + title: Dialog title. + content: Log text to display. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle(title) self.resize(800, 500) diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py index 063dbe9..da9fff9 100644 --- a/pySimBlocks/gui/addons/sofa/sofa_service.py +++ b/pySimBlocks/gui/addons/sofa/sofa_service.py @@ -35,7 +35,26 @@ class SofaService: + """Manage SOFA-specific validation, export, and execution workflows. + + Attributes: + project_state: Project state used to resolve blocks and files. + project_controller: Controller used to access current view state. + sofa_path: Path to the ``runSofa`` executable. + gui: Selected SOFA GUI backend. + scene_file: Resolved SOFA scene file path. + """ + def __init__(self, project_state: ProjectState, project_controller: ProjectController): + """Initialize the SOFA service. + + Args: + project_state: Project state used to resolve blocks and files. + project_controller: Controller used to access current view state. + + Raises: + None. + """ self.project_state = project_state self.project_controller = project_controller @@ -47,9 +66,14 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr # -------------------------------------------------------------------------- - # Public methods + # Public Methods # -------------------------------------------------------------------------- def get_scene_file(self): + """Resolve and cache the scene file used by the SOFA block. + + Returns: + Tuple containing success flag, title, and details message. + """ flag, msg, details = self.can_use_sofa() if flag: sofa_block = [b for b in self.project_state.blocks if b.meta.type in ["sofa_plant", "sofa_exchange_i_o"]] @@ -70,8 +94,12 @@ def get_scene_file(self): else: return flag, msg, details - # ------------------------------------------------------------------ def can_use_sofa(self): + """Check whether the current project can be driven by SOFA. + + Returns: + Tuple containing success flag, title, and details message. + """ 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: return False, "No SOFA block", "Please Add at least one sofa system." @@ -80,16 +108,28 @@ 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): + """Export the generated SOFA controller for the current project. + + Args: + window: Main window used for save confirmation. + saver: Project saver used to persist the project before export. + + Raises: + ValueError: If the project directory is not defined. + """ if window.confirm_discard_or_save("exporting sofa"): saver.save(self.project_controller.project_state, self.project_controller.view.block_items) if self.project_state.directory_path is None: raise ValueError("Project directory is not set.\nPlease define it in settings.") generate_sofa_controller(self.project_state.directory_path) - # ------------------------------------------------------------------ def run(self): + """Run the configured SOFA scene and collect its execution output. + + Returns: + Tuple containing success flag, title, and details message. + """ env_ok, msg = self._check_sofa_environnment() if not env_ok: return False, "Environment error", msg @@ -160,23 +200,24 @@ def run(self): cleanup_runtime_project_yaml(project_dir) # -------------------------------------------------------------------------- - # Internal methods + # Private Methods # -------------------------------------------------------------------------- def _accumulate_output(self): + """Append the latest process output chunk to the accumulated log.""" chunk = self.process.readAllStandardOutput().data().decode() print(chunk, end="") self._full_log += chunk - # ------------------------------------------------------------------ def _check_sofa_environnment(self): + """Validate the environment variables required to run SOFA.""" sofa_root = os.environ.get("SOFA_ROOT") if not sofa_root: return False, "SOFA_ROOT is not set." return True, "OK" - # ------------------------------------------------------------------ def _detect_sofa(self): + """Detect the ``runSofa`` executable from environment or PATH.""" detected = None sofa_root = os.environ.get("SOFA_ROOT") if sofa_root: @@ -196,8 +237,8 @@ def _detect_sofa(self): if detected: self.sofa_path = detected - # ------------------------------------------------------------------ def _resolve_scene_file(self, scene_file: str) -> Path: + """Resolve a scene file path relative to the project directory.""" project_dir = self.project_state.directory_path if project_dir is None: raise RuntimeError("Project directory is not set") diff --git a/pySimBlocks/gui/blocks/block_dialog_session.py b/pySimBlocks/gui/blocks/block_dialog_session.py index f321cc9..88de29d 100644 --- a/pySimBlocks/gui/blocks/block_dialog_session.py +++ b/pySimBlocks/gui/blocks/block_dialog_session.py @@ -30,12 +30,34 @@ class BlockDialogSession: + """Store transient dialog state while editing a block instance. + + Attributes: + meta: Block metadata driving the dialog. + instance: Block instance being edited. + project_dir: Project directory used to resolve relative files. + local_params: Local parameter cache for the open dialog. + param_widgets: Widgets keyed by parameter name. + param_labels: Labels keyed by parameter name. + name_edit: Optional line edit used for the block name. + """ + def __init__( self, meta: "BlockMeta", instance: BlockInstance, project_dir: Path | None = None, ): + """Initialize a block dialog session. + + Args: + meta: Block metadata driving the dialog. + instance: Block instance being edited. + project_dir: Project directory used to resolve relative files. + + Raises: + None. + """ self.meta = meta self.instance = instance self.project_dir = project_dir diff --git a/pySimBlocks/gui/blocks/block_meta.py b/pySimBlocks/gui/blocks/block_meta.py index c3f4b25..04016c8 100644 --- a/pySimBlocks/gui/blocks/block_meta.py +++ b/pySimBlocks/gui/blocks/block_meta.py @@ -47,107 +47,108 @@ class BlockMeta(ABC): + """Define the GUI metadata contract for one block type. - """ - Template for child class - -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 MyBlockMeta(BlockMeta): - - def __init__(self): - self.name = "" - self.category = "" - self.type = "" - self.summary = "" - self.description = ( - "" - ) - - self.parameters = [ - ParameterMeta( - name="", - type="" - ), - ] - - self.inputs = [ - PortMeta( - name="", - display_as="" - shape=... - ), - ] - - self.outputs = [ - PortMeta( - name="", - display_as="" - shape=... - ), - ] + Subclasses declare static block metadata, optional dialog customizations, + and dynamic port-resolution rules used by the GUI layer. """ # ----------- Mandatory class attributes (must be overridden) ----------- + #: User-facing block name. name: str + #: GUI block category. category: str + #: Stable block type identifier. type: str + #: Short summary displayed in the GUI. summary: str + #: Rich description displayed in the dialog. description: str # ----------- Optional declarations ----------- + #: Optional documentation file path, relative to the project directory. doc_path: Path | None = None + #: Declared block parameters. parameters: Sequence[ParameterMeta] = () + #: Declared input port metadata. inputs: Sequence[PortMeta] = () + #: Declared output port metadata. outputs: Sequence[PortMeta] = () # -------------------------------------------------------------------------- - # Dialog session management - # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def create_dialog_session( self, instance: BlockInstance, project_dir: Path | None = None, ) -> BlockDialogSession: + """Create a dialog session for a block instance. + + Args: + instance: Block instance being edited. + project_dir: Project directory used to resolve relative files. + + Returns: + New dialog session object bound to the instance. + """ return BlockDialogSession(self, instance, project_dir) - # -------------------------------------------------------------------------- - # Parameter resolution - # -------------------------------------------------------------------------- def is_parameter_active(self, param_name: str, instance_params: Dict[str, Any]) -> bool: - """ - Default: all parameters are always active. - Children override if needed. + """Return whether a parameter should be visible for an instance. + + Args: + param_name: Parameter name to test. + instance_params: Current instance parameter values. + + Returns: + True when the parameter is active. """ return True - # ------------------------------------------------------------ def gather_params(self, session: BlockDialogSession) -> dict[str, Any]: + """Collect dialog parameters into a serialized parameter mapping. + + Args: + session: Active dialog session. + + Returns: + Parameter mapping gathered from the dialog state. + """ # Keep full local state, including inactive params, so values are cached # across visibility toggles and dialog reopen. return session.local_params.copy() - # -------------------------------------------------------------------------- - # Port resolution - # -------------------------------------------------------------------------- def resolve_port_group(self, port_meta: PortMeta, direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: - """ - Default behavior: fixed port. - Children override for dynamic ports. + """Resolve one declared port group into concrete port instances. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete port instances for the given port group. """ return [PortInstance(port_meta.name, port_meta.display_as, direction, instance)] - # ------------------------------------------------------------ def build_ports(self, instance: "BlockInstance") -> list["PortInstance"]: + """Build all concrete ports for a block instance. + + Args: + instance: Block instance whose ports are being built. + + Returns: + Ordered list of resolved input and output ports. + """ ports = [] for pmeta in self.inputs: @@ -158,12 +159,12 @@ def build_ports(self, instance: "BlockInstance") -> list["PortInstance"]: return ports - - # -------------------------------------------------------------------------- - # QT dialog display - # -------------------------------------------------------------------------- def build_description(self, form: QFormLayout): - """ Default description display. Children can override if needed. """ + """Build the default block description section in the dialog. + + Args: + form: Form layout receiving the description widgets. + """ title = QLabel(f"{self.name}") title.setAlignment(Qt.AlignmentFlag.AlignLeft) form.addRow(title) @@ -187,21 +188,30 @@ def build_description(self, form: QFormLayout): frame_layout.addWidget(desc) form.addRow(frame) - - # ------------------------------------------------------ def build_pre_param(self, session: BlockDialogSession, form: QFormLayout, readonly: bool = False): - """ Default: no pre-parameter widgets. Children override if needed. """ + """Build widgets shown before the standard parameter rows. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ pass - # ------------------------------------------------------------ def build_param(self, session: BlockDialogSession, form: QFormLayout, readonly: bool = False): - """ Default: no parameter widgets. Children override if needed. """ + """Build the standard parameter widgets for the dialog. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ # --- Block name --- @@ -229,16 +239,19 @@ def build_param(self, session.param_widgets[param_name] = widget session.param_labels[param_name] = label - - # ------------------------------------------------------------ def build_post_param(self, session: BlockDialogSession, form: QFormLayout, readonly: bool = False): - """ Default: no post-parameter widgets. Children override if needed. """ + """Build widgets shown after the standard parameter rows. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ pass - # ------------------------------------------------------------ def build_file_param_row( self, session: BlockDialogSession, @@ -247,6 +260,15 @@ def build_file_param_row( readonly: bool = False, file_filter: str = "Python files (*.py);;All files (*)", ) -> None: + """Build a parameter row with a file picker button. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + pmeta: Metadata of the file parameter. + readonly: Whether the dialog is read-only. + file_filter: File picker filter string. + """ edit = self._create_edit_widget(session, pmeta, readonly) if readonly: self._set_readonly_style(edit) @@ -272,11 +294,11 @@ def build_file_param_row( session.param_widgets[pmeta.name] = row_widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------------ def refresh_form(self, session: BlockDialogSession): - """ - Refresh the parameter widgets visibility based on - BlockMeta.is_parameter_active and current local_params. + """Refresh widget visibility from the current local parameter state. + + Args: + session: Active dialog session. """ for param_name, widget in session.param_widgets.items(): @@ -289,13 +311,14 @@ def refresh_form(self, session: BlockDialogSession): # -------------------------------------------------------------------------- - # Private methods + # Private Methods # -------------------------------------------------------------------------- def _create_param_row(self, session: BlockDialogSession, pmeta: ParameterMeta, readonly: bool = False ) -> tuple[QLabel, QWidget]: + """Create the label and widget for one parameter row.""" # ENUM if pmeta.type == "enum": @@ -310,12 +333,11 @@ def _create_param_row(self, return label, widget - - # ------------------------------------------------------------ def _create_edit_widget(self, session: BlockDialogSession, pmeta: ParameterMeta, readonly: bool = False) -> QLineEdit: + """Create a line edit widget for one parameter.""" edit = QLineEdit() value = session.local_params.get(pmeta.name) if value is not None: @@ -327,11 +349,11 @@ def _create_edit_widget(self, ) return edit - # ------------------------------------------------------------ def _create_enum_widget(self, session: BlockDialogSession, pmeta: ParameterMeta, readonly: bool = False) -> QComboBox: + """Create a combo box widget for one enum parameter.""" combo = QComboBox() for v in pmeta.enum: combo.addItem(str(v), userData=v) @@ -343,13 +365,13 @@ def _create_enum_widget(self, ) return combo - # ------------------------------------------------------------ def _browse_and_set_relative_file( self, edit: QLineEdit, project_dir: Path | None, file_filter: str, ) -> None: + """Open a file picker and write back a relative path when possible.""" if project_dir is None: return @@ -378,8 +400,8 @@ def _browse_and_set_relative_file( edit.setText(relative_path.as_posix()) - # ------------------------------------------------------------ def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, readonly: bool,): + """Update local dialog state after a parameter widget changes.""" if readonly: return @@ -393,8 +415,8 @@ def _on_param_changed( self, val: str, name: str, session: BlockDialogSession, r session.local_params[name] = text self.refresh_form(session) - # ------------------------------------------------------------ def _set_readonly_style(self, widget: QWidget): + """Apply a read-only visual style to supported widgets.""" if isinstance(widget, QLineEdit): widget.setReadOnly(True) widget.setStyleSheet(""" diff --git a/pySimBlocks/gui/blocks/controllers/pid.py b/pySimBlocks/gui/blocks/controllers/pid.py index 30750f2..1854e99 100644 --- a/pySimBlocks/gui/blocks/controllers/pid.py +++ b/pySimBlocks/gui/blocks/controllers/pid.py @@ -26,8 +26,17 @@ class PIDMeta(BlockMeta): + """Describe the GUI metadata of the discrete PID controller block.""" def __init__(self): + """Initialize PID block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "PID" self.category = "controllers" self.type = "pid" @@ -104,7 +113,20 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def is_parameter_active(self, param_name: str, instance_params: Dict[str, Any]) -> bool: + """Return whether a PID parameter is active for the selected controller mode. + + Args: + param_name: Parameter name to test. + instance_params: Current instance parameter values. + + Returns: + True if the parameter should be shown. + """ if param_name == "Kp": return instance_params["controller"] in ["P", "PI", "PD", "PID"] diff --git a/pySimBlocks/gui/blocks/controllers/state_feedback.py b/pySimBlocks/gui/blocks/controllers/state_feedback.py index 91dab9d..3d46c03 100644 --- a/pySimBlocks/gui/blocks/controllers/state_feedback.py +++ b/pySimBlocks/gui/blocks/controllers/state_feedback.py @@ -24,7 +24,17 @@ class StateFeedBackMeta(BlockMeta): + """Describe the GUI metadata of the state-feedback controller block.""" + def __init__(self): + """Initialize state-feedback block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "StateFeedback" self.category = "controllers" self.type = "state_feedback" diff --git a/pySimBlocks/gui/blocks/interfaces/external_input.py b/pySimBlocks/gui/blocks/interfaces/external_input.py index 42c04a5..afefa7d 100644 --- a/pySimBlocks/gui/blocks/interfaces/external_input.py +++ b/pySimBlocks/gui/blocks/interfaces/external_input.py @@ -24,8 +24,17 @@ class ExternalInputMeta(BlockMeta): + """Describe the GUI metadata of the external-input interface block.""" def __init__(self): + """Initialize external-input block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "ExternalInput" self.category = "interfaces" self.type = "external_input" diff --git a/pySimBlocks/gui/blocks/interfaces/external_output.py b/pySimBlocks/gui/blocks/interfaces/external_output.py index a5aad8e..d393d8b 100644 --- a/pySimBlocks/gui/blocks/interfaces/external_output.py +++ b/pySimBlocks/gui/blocks/interfaces/external_output.py @@ -24,8 +24,17 @@ class ExternalOutputMeta(BlockMeta): + """Describe the GUI metadata of the external-output interface block.""" def __init__(self): + """Initialize external-output block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "ExternalOutput" self.category = "interfaces" self.type = "external_output" diff --git a/pySimBlocks/gui/blocks/observers/luenberger.py b/pySimBlocks/gui/blocks/observers/luenberger.py index 1290a0b..f9162d9 100644 --- a/pySimBlocks/gui/blocks/observers/luenberger.py +++ b/pySimBlocks/gui/blocks/observers/luenberger.py @@ -24,8 +24,17 @@ class LuenbergerMeta(BlockMeta): + """Describe the GUI metadata of the Luenberger observer block.""" def __init__(self): + """Initialize Luenberger-observer block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Luenberger" self.category = "observers" self.type = "luenberger" diff --git a/pySimBlocks/gui/blocks/operators/algebraic_function.py b/pySimBlocks/gui/blocks/operators/algebraic_function.py index 9fbba46..a01a6b1 100644 --- a/pySimBlocks/gui/blocks/operators/algebraic_function.py +++ b/pySimBlocks/gui/blocks/operators/algebraic_function.py @@ -32,8 +32,17 @@ class AlgebraicFunctionMeta(BlockMeta): + """Describe the GUI metadata of the user-defined algebraic function block.""" def __init__(self): + """Initialize algebraic-function block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "AlgebraicFunction" self.category = "operators" self.type = "algebraic_function" @@ -97,7 +106,7 @@ def __init__(self): # -------------------------------------------------------------------------- - # Port resolution + # Public Methods # -------------------------------------------------------------------------- def resolve_port_group( self, @@ -105,6 +114,16 @@ def resolve_port_group( direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve dynamic input and output ports from configured key lists. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ ports = [] if direction == "input": @@ -139,15 +158,19 @@ def resolve_port_group( return super().resolve_port_group(port_meta, direction, instance) - # -------------------------------------------------------------------------- - # Dialog methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the algebraic-function parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ # --- Block name --- name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( @@ -180,21 +203,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured Python file path for the current session.""" raw = session.local_params.get("file_path") if not raw: return None @@ -204,8 +240,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -218,8 +254,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing file_path to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured source file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/blocks/operators/dead_zone.py b/pySimBlocks/gui/blocks/operators/dead_zone.py index 3636342..4611fc0 100644 --- a/pySimBlocks/gui/blocks/operators/dead_zone.py +++ b/pySimBlocks/gui/blocks/operators/dead_zone.py @@ -24,8 +24,17 @@ class DeadZoneMeta(BlockMeta): + """Describe the GUI metadata of the dead-zone block.""" def __init__(self): + """Initialize dead-zone block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "DeadZone" self.category = "operators" self.type = "dead_zone" diff --git a/pySimBlocks/gui/blocks/operators/delay.py b/pySimBlocks/gui/blocks/operators/delay.py index e3e4cd5..42fa018 100644 --- a/pySimBlocks/gui/blocks/operators/delay.py +++ b/pySimBlocks/gui/blocks/operators/delay.py @@ -24,8 +24,17 @@ class DelayMeta(BlockMeta): + """Describe the GUI metadata of the delay block.""" def __init__(self): + """Initialize delay block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Delay" self.category = "operators" self.type = "delay" diff --git a/pySimBlocks/gui/blocks/operators/demux.py b/pySimBlocks/gui/blocks/operators/demux.py index 9690f67..ad7a2ff 100644 --- a/pySimBlocks/gui/blocks/operators/demux.py +++ b/pySimBlocks/gui/blocks/operators/demux.py @@ -27,8 +27,17 @@ class DemuxMeta(BlockMeta): + """Describe the GUI metadata of the demux block.""" def __init__(self): + """Initialize demux block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Demux" self.category = "operators" self.type = "demux" @@ -74,12 +83,26 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def resolve_port_group( self, port_meta: PortMeta, direction: Literal["input", "output"], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve demux output ports from the configured output count. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "output" and port_meta.name == "out": num_outputs = instance.parameters.get("num_outputs", 0) diff --git a/pySimBlocks/gui/blocks/operators/discrete_derivator.py b/pySimBlocks/gui/blocks/operators/discrete_derivator.py index 261e28b..1d80ab9 100644 --- a/pySimBlocks/gui/blocks/operators/discrete_derivator.py +++ b/pySimBlocks/gui/blocks/operators/discrete_derivator.py @@ -24,8 +24,17 @@ class DiscreteDerivatorMeta(BlockMeta): + """Describe the GUI metadata of the discrete-derivator block.""" def __init__(self): + """Initialize discrete-derivator block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "DiscreteDerivator" self.category = "operators" self.type = "discrete_derivator" diff --git a/pySimBlocks/gui/blocks/operators/discrete_integrator.py b/pySimBlocks/gui/blocks/operators/discrete_integrator.py index 59fdc79..342fa0f 100644 --- a/pySimBlocks/gui/blocks/operators/discrete_integrator.py +++ b/pySimBlocks/gui/blocks/operators/discrete_integrator.py @@ -24,8 +24,17 @@ class DiscreteIntegratorMeta(BlockMeta): + """Describe the GUI metadata of the discrete-integrator block.""" def __init__(self): + """Initialize discrete-integrator block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "DiscreteIntegrator" self.category = "operators" self.type = "discrete_integrator" diff --git a/pySimBlocks/gui/blocks/operators/gain.py b/pySimBlocks/gui/blocks/operators/gain.py index b1c886d..7f8a8c2 100644 --- a/pySimBlocks/gui/blocks/operators/gain.py +++ b/pySimBlocks/gui/blocks/operators/gain.py @@ -24,8 +24,17 @@ class GainMeta(BlockMeta): + """Describe the GUI metadata of the gain block.""" def __init__(self): + """Initialize gain block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Gain" self.category = "operators" self.type = "gain" diff --git a/pySimBlocks/gui/blocks/operators/mux.py b/pySimBlocks/gui/blocks/operators/mux.py index 8dc7dc9..fc0f652 100644 --- a/pySimBlocks/gui/blocks/operators/mux.py +++ b/pySimBlocks/gui/blocks/operators/mux.py @@ -26,8 +26,17 @@ class MuxMeta(BlockMeta): + """Describe the GUI metadata of the mux block.""" def __init__(self): + """Initialize mux block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Mux" self.category = "operators" self.type = "mux" @@ -76,11 +85,25 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def resolve_port_group(self, port_meta: PortMeta, direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve mux input ports from the configured input count. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "input" and port_meta.name == "in": num_inputs = instance.parameters.get("num_inputs", 0) diff --git a/pySimBlocks/gui/blocks/operators/product.py b/pySimBlocks/gui/blocks/operators/product.py index 90bff20..215d865 100644 --- a/pySimBlocks/gui/blocks/operators/product.py +++ b/pySimBlocks/gui/blocks/operators/product.py @@ -27,8 +27,17 @@ class ProductMeta(BlockMeta): + """Describe the GUI metadata of the dynamic multi-input product block.""" def __init__(self): + """Initialize product block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Product" self.category = "operators" self.type = "product" @@ -84,12 +93,26 @@ def __init__(self): ) ] + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def resolve_port_group( self, port_meta: PortMeta, direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve product input ports from the configured operations string. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "input" and port_meta.name == "in": operations_str = instance.parameters.get("operations", "") diff --git a/pySimBlocks/gui/blocks/operators/rate_limiter.py b/pySimBlocks/gui/blocks/operators/rate_limiter.py index 61f77d6..22dea6e 100644 --- a/pySimBlocks/gui/blocks/operators/rate_limiter.py +++ b/pySimBlocks/gui/blocks/operators/rate_limiter.py @@ -24,8 +24,17 @@ class RateLimiterMeta(BlockMeta): + """Describe the GUI metadata of the rate-limiter block.""" def __init__(self): + """Initialize rate-limiter block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "RateLimiter" self.category = "operators" self.type = "rate_limiter" diff --git a/pySimBlocks/gui/blocks/operators/saturation.py b/pySimBlocks/gui/blocks/operators/saturation.py index 226888b..ab949a7 100644 --- a/pySimBlocks/gui/blocks/operators/saturation.py +++ b/pySimBlocks/gui/blocks/operators/saturation.py @@ -24,8 +24,17 @@ class SaturationMeta(BlockMeta): + """Describe the GUI metadata of the saturation block.""" def __init__(self): + """Initialize saturation block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Saturation" self.category = "operators" self.type = "saturation" diff --git a/pySimBlocks/gui/blocks/operators/sum.py b/pySimBlocks/gui/blocks/operators/sum.py index 3095c85..314489f 100644 --- a/pySimBlocks/gui/blocks/operators/sum.py +++ b/pySimBlocks/gui/blocks/operators/sum.py @@ -25,8 +25,17 @@ from pySimBlocks.gui.models import BlockInstance, PortInstance class SumMeta(BlockMeta): + """Describe the GUI metadata of the dynamic multi-input sum block.""" def __init__(self): + """Initialize sum block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Sum" self.category = "operators" self.type = "sum" @@ -66,12 +75,26 @@ def __init__(self): shape=["n", "m"] ), ] - + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def resolve_port_group(self, port_meta: PortMeta, direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve sum input ports from the configured sign string. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "input" and port_meta.name == "in": signs = instance.parameters.get("signs", "") @@ -87,5 +110,5 @@ def resolve_port_group(self, ) ) return ports - + return super().resolve_port_group(port_meta, direction, instance) diff --git a/pySimBlocks/gui/blocks/operators/zero_order_hold.py b/pySimBlocks/gui/blocks/operators/zero_order_hold.py index 5544db8..9f1f7c5 100644 --- a/pySimBlocks/gui/blocks/operators/zero_order_hold.py +++ b/pySimBlocks/gui/blocks/operators/zero_order_hold.py @@ -24,8 +24,17 @@ class ZeroOrderHoldMeta(BlockMeta): + """Describe the GUI metadata of the zero-order-hold block.""" def __init__(self): + """Initialize zero-order-hold block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "ZeroOrderHold" self.category = "operators" self.type = "zero_order_hold" diff --git a/pySimBlocks/gui/blocks/optimizers/quadratic_program.py b/pySimBlocks/gui/blocks/optimizers/quadratic_program.py index a07f69a..6ec9098 100644 --- a/pySimBlocks/gui/blocks/optimizers/quadratic_program.py +++ b/pySimBlocks/gui/blocks/optimizers/quadratic_program.py @@ -24,8 +24,17 @@ class QuadraticProgramMeta(BlockMeta): + """Describe the GUI metadata of the quadratic-program optimizer block.""" def __init__(self): + """Initialize quadratic-program block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "QuadraticProgram" self.category = "optimizers" self.type = "quadratic_program" diff --git a/pySimBlocks/gui/blocks/parameter_meta.py b/pySimBlocks/gui/blocks/parameter_meta.py index b3b9afd..8ba87cb 100644 --- a/pySimBlocks/gui/blocks/parameter_meta.py +++ b/pySimBlocks/gui/blocks/parameter_meta.py @@ -25,10 +25,25 @@ @dataclass(frozen=True) class ParameterMeta: + """Describe one configurable parameter of a GUI block.""" + + #: Parameter name. name: str + + #: User-facing parameter type description. type: str + + #: Whether the parameter must be provided. required: bool = False + + #: Whether a default value should be inserted automatically. autofill: bool = False + + #: Default parameter value. default: Optional[Any] = None + + #: Allowed values for enum-like parameters. enum: List[Any] = field(default_factory=list) + + #: Optional help text displayed in the GUI. description: str = "" diff --git a/pySimBlocks/gui/blocks/port_meta.py b/pySimBlocks/gui/blocks/port_meta.py index d056e39..486d576 100644 --- a/pySimBlocks/gui/blocks/port_meta.py +++ b/pySimBlocks/gui/blocks/port_meta.py @@ -25,7 +25,16 @@ @dataclass(frozen=True) class PortMeta: + """Describe one declared input or output port of a GUI block.""" + + #: Internal port name. name: str + + #: User-facing port label. display_as: str + + #: Symbolic shape description shown in metadata. shape: list[Any] + + #: Optional help text displayed in the GUI. description: str = "" diff --git a/pySimBlocks/gui/blocks/sources/chirp.py b/pySimBlocks/gui/blocks/sources/chirp.py index 82f44c6..c6e1ac9 100644 --- a/pySimBlocks/gui/blocks/sources/chirp.py +++ b/pySimBlocks/gui/blocks/sources/chirp.py @@ -24,8 +24,17 @@ class ChirpMeta(BlockMeta): + """Describe the GUI metadata of the chirp source block.""" def __init__(self): + """Initialize chirp-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Chirp" self.category = "sources" self.type = "chirp" diff --git a/pySimBlocks/gui/blocks/sources/constant.py b/pySimBlocks/gui/blocks/sources/constant.py index 3f1aa11..05ebcb5 100644 --- a/pySimBlocks/gui/blocks/sources/constant.py +++ b/pySimBlocks/gui/blocks/sources/constant.py @@ -24,8 +24,17 @@ class ConstantMeta(BlockMeta): + """Describe the GUI metadata of the constant source block.""" def __init__(self): + """Initialize constant-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Constant" self.category = "sources" self.type = "constant" diff --git a/pySimBlocks/gui/blocks/sources/file_source.py b/pySimBlocks/gui/blocks/sources/file_source.py index 5e3a5e8..f5fb64a 100644 --- a/pySimBlocks/gui/blocks/sources/file_source.py +++ b/pySimBlocks/gui/blocks/sources/file_source.py @@ -32,8 +32,17 @@ class FileSourceMeta(BlockMeta): + """Describe the GUI metadata of the file-backed source block.""" def __init__(self): + """Initialize file-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "FileSource" self.category = "sources" self.type = "file_source" @@ -90,10 +99,21 @@ def __init__(self): ) ] - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- def is_parameter_active(self, param_name: str, instance_params: Dict[str, Any]) -> bool: + """Return whether a file-source parameter is relevant for the file type. + + Args: + param_name: Parameter name to test. + instance_params: Current instance parameter values. + + Returns: + True if the parameter should be shown. + """ file_path = str(instance_params.get("file_path", "") or "") ext = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else "" @@ -105,15 +125,19 @@ def is_parameter_active(self, return super().is_parameter_active(param_name, instance_params) - # -------------------------------------------------------------------------- - # Dialog Methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the file-source parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( lambda val: self._on_param_changed(val, "name", session, readonly) @@ -144,21 +168,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured data file path for the current session.""" raw = session.local_params.get("file_path") if not raw: return None @@ -168,8 +205,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -182,8 +219,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing file_path to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured data file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/blocks/sources/function_source.py b/pySimBlocks/gui/blocks/sources/function_source.py index 799adb5..98b2b03 100644 --- a/pySimBlocks/gui/blocks/sources/function_source.py +++ b/pySimBlocks/gui/blocks/sources/function_source.py @@ -32,8 +32,17 @@ class FunctionSourceMeta(BlockMeta): + """Describe the GUI metadata of the user-defined function source block.""" def __init__(self): + """Initialize function-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "FunctionSource" self.category = "sources" self.type = "function_source" @@ -87,7 +96,7 @@ def __init__(self): ] # -------------------------------------------------------------------------- - # Port resolution + # Public Methods # -------------------------------------------------------------------------- def resolve_port_group( self, @@ -95,6 +104,16 @@ def resolve_port_group( direction: Literal["input", "output"], instance: "BlockInstance", ) -> list["PortInstance"]: + """Resolve output ports from the configured output key list. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "output": keys = instance.parameters.get("output_keys", []) if keys is None: @@ -111,15 +130,19 @@ def resolve_port_group( return super().resolve_port_group(port_meta, direction, instance) - # -------------------------------------------------------------------------- - # Dialog methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the function-source parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( lambda val: self._on_param_changed(val, "name", session, readonly) @@ -150,21 +173,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured Python file path for the current session.""" raw = session.local_params.get("file_path") if not raw: return None @@ -174,8 +210,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -188,8 +224,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing file_path to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured source file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/blocks/sources/ramp.py b/pySimBlocks/gui/blocks/sources/ramp.py index aa8a77a..9f99f5d 100644 --- a/pySimBlocks/gui/blocks/sources/ramp.py +++ b/pySimBlocks/gui/blocks/sources/ramp.py @@ -24,8 +24,17 @@ class RampMeta(BlockMeta): + """Describe the GUI metadata of the ramp source block.""" def __init__(self): + """Initialize ramp-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Ramp" self.category = "sources" self.type = "ramp" diff --git a/pySimBlocks/gui/blocks/sources/sinusoidal.py b/pySimBlocks/gui/blocks/sources/sinusoidal.py index da60bb4..321eaa4 100644 --- a/pySimBlocks/gui/blocks/sources/sinusoidal.py +++ b/pySimBlocks/gui/blocks/sources/sinusoidal.py @@ -24,8 +24,17 @@ class SinusoidalMeta(BlockMeta): + """Describe the GUI metadata of the sinusoidal source block.""" def __init__(self): + """Initialize sinusoidal-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Sinusoidal" self.category = "sources" self.type = "sinusoidal" diff --git a/pySimBlocks/gui/blocks/sources/step.py b/pySimBlocks/gui/blocks/sources/step.py index 315761d..a278100 100644 --- a/pySimBlocks/gui/blocks/sources/step.py +++ b/pySimBlocks/gui/blocks/sources/step.py @@ -24,8 +24,17 @@ class StepMeta(BlockMeta): + """Describe the GUI metadata of the step source block.""" def __init__(self): + """Initialize step-source block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Step" self.category = "sources" self.type = "step" diff --git a/pySimBlocks/gui/blocks/sources/white_noise.py b/pySimBlocks/gui/blocks/sources/white_noise.py index 8fe96de..26ab05b 100644 --- a/pySimBlocks/gui/blocks/sources/white_noise.py +++ b/pySimBlocks/gui/blocks/sources/white_noise.py @@ -24,8 +24,17 @@ class WhiteNoiseMeta(BlockMeta): + """Describe the GUI metadata of the white-noise source block.""" def __init__(self): + """Initialize white-noise block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "WhiteNoise" self.category = "sources" self.type = "white_noise" diff --git a/pySimBlocks/gui/blocks/systems/linear_state_space.py b/pySimBlocks/gui/blocks/systems/linear_state_space.py index de6b58e..047c62a 100644 --- a/pySimBlocks/gui/blocks/systems/linear_state_space.py +++ b/pySimBlocks/gui/blocks/systems/linear_state_space.py @@ -24,8 +24,17 @@ class LinearStateSpaceMeta(BlockMeta): + """Describe the GUI metadata of the linear state-space block.""" def __init__(self): + """Initialize linear-state-space block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "LinearStateSpace" self.category = "systems" self.type = "linear_state_space" diff --git a/pySimBlocks/gui/blocks/systems/non_linear_state_space.py b/pySimBlocks/gui/blocks/systems/non_linear_state_space.py index 956d2ce..e82a4f0 100644 --- a/pySimBlocks/gui/blocks/systems/non_linear_state_space.py +++ b/pySimBlocks/gui/blocks/systems/non_linear_state_space.py @@ -32,8 +32,17 @@ class NonLinearStateSpaceMeta(BlockMeta): + """Describe the GUI metadata of the user-defined nonlinear state-space block.""" def __init__(self): + """Initialize nonlinear-state-space block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "Non Linear State Space" self.category = "systems" self.type = "non_linear_state_space" @@ -108,7 +117,7 @@ def __init__(self): ] # -------------------------------------------------------------------------- - # Port resolution + # Public Methods # -------------------------------------------------------------------------- def resolve_port_group( self, @@ -116,6 +125,16 @@ def resolve_port_group( direction: Literal['input', 'output'], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve dynamic input and output ports from configured key lists. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == 'input' and port_meta.name == 'in': input_keys = instance.parameters.get("input_keys", []) if input_keys is None: @@ -142,15 +161,19 @@ def resolve_port_group( ] return super().resolve_port_group(port_meta, direction, instance) - # -------------------------------------------------------------------------- - # Dialog methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the nonlinear-state-space parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( lambda val: self._on_param_changed(val, "name", session, readonly) @@ -181,21 +204,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured Python file path for the current session.""" raw = session.local_params.get("file_path") if not raw: return None @@ -205,8 +241,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -219,8 +255,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing file_path to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured source file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/blocks/systems/polytopic_state_space.py b/pySimBlocks/gui/blocks/systems/polytopic_state_space.py index ee142d0..6ef860c 100644 --- a/pySimBlocks/gui/blocks/systems/polytopic_state_space.py +++ b/pySimBlocks/gui/blocks/systems/polytopic_state_space.py @@ -24,8 +24,17 @@ class PolytopicStateSpaceMeta(BlockMeta): + """Describe the GUI metadata of the polytopic state-space block.""" def __init__(self): + """Initialize polytopic-state-space block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "PolytopicStateSpace" self.category = "systems" self.type = "polytopic_state_space" diff --git a/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py b/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py index 5004e41..286dc87 100644 --- a/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py @@ -33,8 +33,17 @@ class SofaExchangeIOMeta(BlockMeta): + """Describe the GUI metadata of the SOFA exchange I/O block.""" def __init__(self): + """Initialize SOFA exchange I/O block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "SofaExchangeIO" self.category = "systems" self.type = "sofa_exchange_i_o" @@ -91,7 +100,7 @@ def __init__(self): ] # -------------------------------------------------------------------------- - # Port Resolution + # Public Methods # -------------------------------------------------------------------------- def resolve_port_group( self, @@ -99,6 +108,16 @@ def resolve_port_group( direction: Literal["input", "output"], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve dynamic SOFA input and output ports from configured key lists. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "input" and port_meta.name == "sofa_inputs": keys = instance.parameters.get("input_keys", []) @@ -130,15 +149,19 @@ def resolve_port_group( return super().resolve_port_group(port_meta, direction, instance) - # -------------------------------------------------------------------------- - # Dialog Methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the SOFA exchange I/O parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( lambda val: self._on_param_changed(val, "name", session, readonly) @@ -169,21 +192,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured SOFA scene path for the current session.""" raw = session.local_params.get("scene_file") if not raw: return None @@ -193,8 +229,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -207,8 +243,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing scene_file to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured scene file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py index b730def..294ad60 100644 --- a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py @@ -33,8 +33,17 @@ class SofaPlantMeta(BlockMeta): + """Describe the GUI metadata of the SOFA plant block.""" def __init__(self): + """Initialize SOFA-plant block metadata. + + Args: + None. + + Raises: + None. + """ self.name = "SofaPlant" self.category = "systems" self.type = "sofa_plant" @@ -90,7 +99,7 @@ def __init__(self): ] # -------------------------------------------------------------------------- - # Port Resolution + # Public Methods # -------------------------------------------------------------------------- def resolve_port_group( self, @@ -98,6 +107,16 @@ def resolve_port_group( direction: Literal["input", "output"], instance: "BlockInstance" ) -> list["PortInstance"]: + """Resolve dynamic SOFA input and output ports from configured key lists. + + Args: + port_meta: Declared port metadata. + direction: Direction of the port group. + instance: Block instance whose ports are being built. + + Returns: + Concrete ports for the requested port group. + """ if direction == "input" and port_meta.name == "sofa_inputs": keys = instance.parameters.get("input_keys", []) @@ -129,15 +148,19 @@ def resolve_port_group( return super().resolve_port_group(port_meta, direction, instance) - # -------------------------------------------------------------------------- - # Dialog Methods - # -------------------------------------------------------------------------- def build_param( self, session, form: QFormLayout, readonly: bool = False, ): + """Build the SOFA-plant parameter editor. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ name_edit = QLineEdit(session.instance.name) name_edit.textChanged.connect( lambda val: self._on_param_changed(val, "name", session, readonly) @@ -168,21 +191,34 @@ def build_param( session.param_widgets[pmeta.name] = widget session.param_labels[pmeta.name] = label - # ------------------------------------------------------ def build_post_param(self, session, form: QFormLayout, readonly: bool = False): + """Build the post-parameter section with the open-file action. + + Args: + session: Active dialog session. + form: Form layout receiving the widgets. + readonly: Whether the dialog is read-only. + """ open_btn = QPushButton("Open file") open_btn.clicked.connect(lambda: self._open_file_from_session(session)) form.addRow(QLabel(""), open_btn) session.open_file_btn = open_btn self._refresh_open_button_state(session) - # ------------------------------------------------------ def refresh_form(self, session): + """Refresh widget visibility and the file-open button state. + + Args: + session: Active dialog session. + """ super().refresh_form(session) self._refresh_open_button_state(session) - # ------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _resolve_file_path(self, session) -> Path | None: + """Resolve the configured SOFA scene path for the current session.""" raw = session.local_params.get("scene_file") if not raw: return None @@ -192,8 +228,8 @@ def _resolve_file_path(self, session) -> Path | None: path = (session.project_dir / path).resolve() return path - # ------------------------------------------------------ def _refresh_open_button_state(self, session) -> None: + """Enable or disable the open-file button from the resolved path.""" btn = getattr(session, "open_file_btn", None) if btn is None: return @@ -206,8 +242,8 @@ def _refresh_open_button_state(self, session) -> None: else: btn.setToolTip("Set a valid existing scene_file to open the file.") - # ------------------------------------------------------ def _open_file_from_session(self, session) -> None: + """Open the configured scene file with the platform default app.""" target = self._resolve_file_path(session) if target is None or not target.is_file(): return diff --git a/pySimBlocks/gui/dialogs/block_dialog.py b/pySimBlocks/gui/dialogs/block_dialog.py index 5c7f46f..321c349 100644 --- a/pySimBlocks/gui/dialogs/block_dialog.py +++ b/pySimBlocks/gui/dialogs/block_dialog.py @@ -36,10 +36,29 @@ class BlockDialog(QDialog): + """Edit or inspect the parameters of a block instance. + + Attributes: + block: Block item being edited or inspected. + meta: Block metadata describing the dialog content. + instance: Block instance bound to the dialog. + readonly: Whether the dialog is read-only. + session: Metadata-defined dialog session object. + """ + def __init__(self, block: 'BlockItem', readonly: bool = False ): + """Initialize a block dialog. + + Args: + block: Block item being edited or inspected. + readonly: If True, disable parameter edition. + + Raises: + None. + """ super().__init__() self.block = block self.meta = block.instance.meta @@ -64,9 +83,14 @@ def __init__(self, self.build_buttons_layout(main_layout) # -------------------------------------------------------------------------- - # Dialog Layout Methods + # Public Methods # -------------------------------------------------------------------------- def build_meta_layout(self, layout: QVBoxLayout): + """Build the metadata-defined parameter form. + + Args: + layout: Parent layout receiving the form. + """ form = QFormLayout() self.meta.build_description(form) self.meta.build_pre_param(self.session, form, self.readonly) @@ -76,6 +100,11 @@ def build_meta_layout(self, layout: QVBoxLayout): self.meta.refresh_form(self.session) def build_buttons_layout(self, layout: QVBoxLayout): + """Build the dialog action buttons. + + Args: + layout: Parent layout receiving the button row. + """ buttons_layout = QHBoxLayout() buttons_layout.addStretch() @@ -99,24 +128,21 @@ def build_buttons_layout(self, layout: QVBoxLayout): layout.addLayout(buttons_layout) - - # -------------------------------------------------------------------------- - # Button Methods - # -------------------------------------------------------------------------- def apply(self): + """Apply current dialog values to the bound block.""" if self.readonly: return params = self.meta.gather_params(self.session) self.block.view.update_block_param_event(self.block.instance, params) - # ------------------------------------------------------------ def ok(self): + """Apply current values and close the dialog.""" self.apply() self.accept() - # ------------------------------------------------------------ def open_help(self): + """Open the block help dialog if documentation is available.""" help_path = self.block.instance.meta.doc_path if help_path and help_path.exists(): diff --git a/pySimBlocks/gui/dialogs/display_yaml_dialog.py b/pySimBlocks/gui/dialogs/display_yaml_dialog.py index 077d887..0aa9d3f 100644 --- a/pySimBlocks/gui/dialogs/display_yaml_dialog.py +++ b/pySimBlocks/gui/dialogs/display_yaml_dialog.py @@ -34,10 +34,27 @@ class DisplayYamlDialog(QDialog): + """Preview the generated project YAML inside a dialog. + + Attributes: + project_state: Project state being previewed. + view: Diagram view supplying block layout information. + """ + def __init__(self, project: ProjectState, view: DiagramView, parent=None): + """Initialize the YAML preview dialog. + + Args: + project: Project state being previewed. + view: Diagram view supplying block layout information. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle("project.yaml Preview") @@ -77,10 +94,11 @@ def __init__(self, main_layout.addLayout(buttons) - # ------------------------------------------------- - # Helpers - # ------------------------------------------------- + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _make_code_view(self, text:str) -> QTextEdit: + """Build a read-only text editor configured for code display.""" edit = QTextEdit() edit.setReadOnly(True) edit.setFont(QFont("Courier New", 10)) diff --git a/pySimBlocks/gui/dialogs/help_dialog.py b/pySimBlocks/gui/dialogs/help_dialog.py index 913adfe..983b100 100644 --- a/pySimBlocks/gui/dialogs/help_dialog.py +++ b/pySimBlocks/gui/dialogs/help_dialog.py @@ -23,7 +23,18 @@ class HelpDialog(QDialog): + """Display Markdown help content for a block or feature.""" + def __init__(self, md_path: Path, parent=None): + """Initialize a help dialog. + + Args: + md_path: Markdown file to display. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle(md_path.name) self.resize(600, 500) diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index 8c48900..b9e2ba2 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -37,7 +37,23 @@ class PlotDialog(QDialog): + """Preview logged signals and launch configured plot windows. + + Attributes: + project_state: Project state providing logs and plot definitions. + selected_signals: Signals currently selected for preview. + """ + def __init__(self, project_state: ProjectState, parent=None): + """Initialize the plot dialog. + + Args: + project_state: Project state providing logs and plot definitions. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle("Plot signals") self.resize(900, 500) @@ -48,10 +64,11 @@ def __init__(self, project_state: ProjectState, parent=None): self._build_ui() self._populate_signals() - # ------------------------------------------------------------ - # UI - # ------------------------------------------------------------ + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- def _build_ui(self): + """Build the plot dialog user interface.""" main_layout = QHBoxLayout(self) # ---------- Left panel ---------- @@ -78,10 +95,8 @@ def _build_ui(self): main_layout.addWidget(self.canvas, 1) - # ------------------------------------------------------------ - # Populate signals - # ------------------------------------------------------------ def _populate_signals(self): + """Populate the signal list from the available logged signals.""" self.signal_list.clear() for sig in sorted(self.project_state.logs.keys()): @@ -93,10 +108,8 @@ def _populate_signals(self): item.setCheckState(Qt.Unchecked) self.signal_list.addItem(item) - # ------------------------------------------------------------ - # Interactive plot - # ------------------------------------------------------------ def _on_signal_toggled(self, item: QListWidgetItem): + """Update the selected signal set when a checkbox changes.""" sig = item.text() if item.checkState() == Qt.Checked: @@ -108,14 +121,17 @@ def _on_signal_toggled(self, item: QListWidgetItem): def _stack_logged_signal_2d(self, sig: str) -> np.ndarray: - """ - Stack a logged signal over time, preserving its 2D shape. + """Stack a logged signal over time while preserving its 2D shape. + + Args: + sig: Signal name to stack from the logs. Returns: - data: np.ndarray of shape (T, m, n) + Array of shape ``(T, m, n)`` containing the stacked samples. Raises: - ValueError if samples are not 2D arrays of consistent shape. + ValueError: If the signal is missing, contains ``None``, or its + samples are not consistent 2D arrays. """ samples = self.project_state.logs.get(sig, None) if not isinstance(samples, list) or len(samples) == 0: @@ -155,6 +171,7 @@ def _stack_logged_signal_2d(self, sig: str) -> np.ndarray: def _update_preview_plot(self): + """Redraw the embedded preview plot from the selected signals.""" self.figure.clear() ax = self.figure.add_subplot(111) @@ -210,10 +227,8 @@ def _update_preview_plot(self): self.canvas.draw() - # ------------------------------------------------------------ - # Plot defined plots (matplotlib windows) - # ------------------------------------------------------------ def _plot_defined_plots(self): + """Open standalone matplotlib windows for the configured plots.""" if not self.project_state.plots: QMessageBox.information( self, diff --git a/pySimBlocks/gui/dialogs/settings/plots.py b/pySimBlocks/gui/dialogs/settings/plots.py index 0687746..2e67b3f 100644 --- a/pySimBlocks/gui/dialogs/settings/plots.py +++ b/pySimBlocks/gui/dialogs/settings/plots.py @@ -29,7 +29,24 @@ class PlotSettingsWidget(QWidget): + """Edit the set of named plots stored in the project. + + Attributes: + project_state: Project state edited by the widget. + project_controller: Controller applying plot changes. + edit_index: Index of the plot currently being edited, or None. + """ + def __init__(self, project_state: ProjectState, project_controller: ProjectController): + """Initialize the plot settings widget. + + Args: + project_state: Project state edited by the widget. + project_controller: Controller applying plot changes. + + Raises: + None. + """ super().__init__() self.project_state = project_state self.project_controller = project_controller @@ -82,9 +99,10 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr self.populate_signal_list() self.update_buttons_state() - # ================================================== - # Helpers - # ================================================== + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def refresh_from_project(self): """Synchronize the plot editor with the current project state.""" self.edit_index = None @@ -95,14 +113,16 @@ def refresh_from_project(self): self.update_buttons_state() def refresh_plot_list(self): + """Refresh the plot titles shown in the list widget.""" self.plot_list.clear() for plot in self.project_state.plots: self.plot_list.addItem(plot["title"]) def populate_signal_list(self, checked=None): - """ - Populate signal list. - checked: optional set/list of signals to check. + """Populate the signal checklist. + + Args: + checked: Optional iterable of signal names to preselect. """ self.signal_list.clear() checked = set(checked or []) @@ -114,6 +134,11 @@ def populate_signal_list(self, checked=None): self.signal_list.addItem(item) def collect_selected_signals(self) -> list[str]: + """Return the currently selected signal names. + + Returns: + Selected signal names from the checklist. + """ return [ self.signal_list.item(i).text() for i in range(self.signal_list.count()) @@ -121,17 +146,21 @@ def collect_selected_signals(self) -> list[str]: ] def reset_form(self): + """Clear the editor fields and uncheck all signals.""" self.title_edit.clear() self.populate_signal_list() def update_buttons_state(self): + """Enable or disable actions based on the current selection state.""" has_selection = self.plot_list.currentRow() >= 0 self.del_btn.setEnabled(has_selection) - # ================================================== - # Selection handling - # ================================================== def load_plot(self, index): + """Load the selected plot into the editor. + + Args: + index: Index of the plot selected in the list. + """ if index < 0: self.edit_index = None self.reset_form() @@ -145,16 +174,15 @@ def load_plot(self, index): self.update_buttons_state() - # ================================================== - # Actions - # ================================================== def new_plot(self): + """Start editing a new plot definition.""" self.edit_index = None self.plot_list.clearSelection() self.reset_form() self.update_buttons_state() def save_plot(self): + """Create or update the currently edited plot.""" title = self.title_edit.text().strip() if not title: QMessageBox.warning(self, "Invalid plot", "Plot title cannot be empty.") @@ -176,6 +204,7 @@ def save_plot(self): def delete_plot(self): + """Delete the currently selected plot.""" if self.edit_index is None: return diff --git a/pySimBlocks/gui/dialogs/settings/project.py b/pySimBlocks/gui/dialogs/settings/project.py index 45f6f04..99f2436 100644 --- a/pySimBlocks/gui/dialogs/settings/project.py +++ b/pySimBlocks/gui/dialogs/settings/project.py @@ -30,8 +30,25 @@ class ProjectSettingsWidget(QWidget): + """Edit project-level settings such as paths and external modules. + + Attributes: + project_state: Project state edited by the widget. + project_controller: Controller applying the edited settings. + settings_dialog: Parent settings dialog coordinating tab refreshes. + """ def __init__(self, project_state: ProjectState, project_controller: ProjectController, settings_dialg): + """Initialize the project settings widget. + + Args: + project_state: Project state edited by the widget. + project_controller: Controller applying the edited settings. + settings_dialg: Parent settings dialog coordinating refreshes. + + Raises: + None. + """ super().__init__() self.project_state = project_state self.project_controller = project_controller @@ -75,7 +92,16 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def apply(self) -> bool: + """Validate and apply the current project settings. + + Returns: + True if the settings were applied successfully, otherwise False. + """ path = Path(self.dir_edit.text()) if not path.exists(): QMessageBox.warning( @@ -89,6 +115,7 @@ def apply(self) -> bool: return True def browse_external_file(self): + """Select an external Python file relative to the project directory.""" base_dir = Path(self.dir_edit.text()).expanduser() if not base_dir.is_dir(): QMessageBox.warning( @@ -121,6 +148,7 @@ def browse_external_file(self): self.external_edit.setText(relative_path.as_posix()) def browse_project_directory(self): + """Select the project directory from the filesystem.""" current_dir = Path(self.dir_edit.text()).expanduser() start_dir = current_dir if current_dir.is_dir() else Path.cwd() @@ -136,6 +164,7 @@ def browse_project_directory(self): self.dir_edit.setText(str(Path(selected_dir).resolve())) def load_project(self): + """Load the project from the selected directory after confirmation.""" main_window = self.settings_dialog.parent() if not main_window.confirm_discard_or_save("loading a new project"): return diff --git a/pySimBlocks/gui/dialogs/settings/simulation.py b/pySimBlocks/gui/dialogs/settings/simulation.py index 5dd7b77..85a26ab 100644 --- a/pySimBlocks/gui/dialogs/settings/simulation.py +++ b/pySimBlocks/gui/dialogs/settings/simulation.py @@ -29,7 +29,23 @@ class SimulationSettingsWidget(QWidget): + """Edit simulation parameters and logged signals for the project. + + Attributes: + project_state: Project state edited by the widget. + project_controller: Controller applying simulation changes. + """ + def __init__(self, project_state: ProjectState, project_controller: ProjectController): + """Initialize the simulation settings widget. + + Args: + project_state: Project state edited by the widget. + project_controller: Controller applying simulation changes. + + Raises: + None. + """ super().__init__() self.project_state = project_state self.project_controller = project_controller @@ -64,7 +80,12 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr self._define_log_list() layout.addRow("Signals logged:", self.logs_list) + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def apply(self): + """Apply the edited simulation parameters and logging selection.""" params = {} try: @@ -90,10 +111,7 @@ def apply(self): self._logs_dirty = False def refresh_from_project(self): - """ - Synchronize the log checkbox list with project_state.logging. - Called when the Simulation tab becomes active. - """ + """Synchronize the widget from the current project state.""" self._define_log_list() selected = set(self.project_state.logging) self.logs_list.blockSignals(True) @@ -108,7 +126,12 @@ def refresh_from_project(self): self.logs_list.blockSignals(False) self._logs_dirty = False + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _define_log_list(self): + """Populate the log list from the current project outputs.""" self.logs_list.blockSignals(True) self.logs_list.clear() available = self.project_state.get_output_signals() @@ -122,6 +145,7 @@ def _define_log_list(self): self._logs_dirty = False def _on_log_changed(self, item: QListWidgetItem): + """Track logging changes and prevent removing signals used by plots.""" self._logs_dirty = True if item.checkState() == Qt.Unchecked: used = any( diff --git a/pySimBlocks/gui/dialogs/settings_dialog.py b/pySimBlocks/gui/dialogs/settings_dialog.py index d25f0de..72ff221 100644 --- a/pySimBlocks/gui/dialogs/settings_dialog.py +++ b/pySimBlocks/gui/dialogs/settings_dialog.py @@ -30,7 +30,26 @@ class SettingsDialog(QDialog): + """Display project, simulation, and plot settings in a tabbed dialog. + + Attributes: + tabs: Tab widget containing all settings pages. + project_tab: Project settings widget. + simulation_tab: Simulation settings widget. + plots_tab: Plot settings widget. + """ + def __init__(self, project_state: ProjectState, project_controller: ProjectController, parent=None): + """Initialize the settings dialog. + + Args: + project_state: Project state edited by the dialog. + project_controller: Controller applying the edited settings. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle("Settings") self.setMinimumWidth(500) @@ -67,21 +86,32 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr layout.addLayout(buttons) + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def ok(self): + """Apply settings and close the dialog.""" self.apply() self.accept() def apply(self): + """Apply the currently edited settings to the project.""" if not self.project_tab.apply(): return self.simulation_tab.apply() def refresh_tabs_from_project(self): + """Refresh dependent tabs from the current project state.""" self.simulation_tab.refresh_from_project() self.plots_tab.refresh_from_project() + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _on_tab_changed(self, index): - #save curr widget + """Refresh the newly selected tab when it supports project syncing.""" widget = self.tabs.widget(index) if hasattr(widget, "refresh_from_project"): diff --git a/pySimBlocks/gui/dialogs/unsaved_dialog.py b/pySimBlocks/gui/dialogs/unsaved_dialog.py index 0bd8ec5..076df39 100644 --- a/pySimBlocks/gui/dialogs/unsaved_dialog.py +++ b/pySimBlocks/gui/dialogs/unsaved_dialog.py @@ -26,11 +26,22 @@ class UnsavedChangesDialog(QDialog): + """Prompt the user about unsaved changes before a disruptive action.""" + SAVE = 1 DISCARD = 2 CANCEL = 3 def __init__(self, action_name: str, parent=None): + """Initialize the unsaved-changes dialog. + + Args: + action_name: Action the user is about to perform. + parent: Optional parent widget. + + Raises: + None. + """ super().__init__(parent) self.setWindowTitle("Unsaved changes") @@ -83,10 +94,18 @@ def __init__(self, action_name: str, parent=None): layout.addSpacing(12) layout.addLayout(buttons_layout) - # ❌ Esc does nothing + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def reject(self): + """Ignore Escape-based dialog rejection to force an explicit choice.""" pass - # ❌ Close button (X) does nothing def closeEvent(self, event): + """Ignore window-close events to force an explicit choice. + + Args: + event: Qt close event. + """ event.ignore() diff --git a/pySimBlocks/gui/editor.py b/pySimBlocks/gui/editor.py index 29ceb74..b9d6557 100644 --- a/pySimBlocks/gui/editor.py +++ b/pySimBlocks/gui/editor.py @@ -18,16 +18,21 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import sys import os from pathlib import Path from PySide6.QtWidgets import QApplication from pySimBlocks.gui.main_window import MainWindow -# ============================================================ -# Entry point -# ============================================================ -def main(): + +def main() -> None: + """Entry point for the pySimBlocks GUI editor. + + Reads an optional project directory from the command-line arguments, + defaulting to the current working directory, then launches the application. + """ if len(sys.argv) > 1: project_dir = os.path.abspath(sys.argv[1]) else: @@ -36,7 +41,13 @@ def main(): run_app(project_path) -def run_app(project_path: Path): +def run_app(project_path: Path) -> None: + """Create and start the Qt application with a :class:`MainWindow`. + + Args: + project_path: Resolved path to the project directory to open on + startup. + """ app = QApplication(sys.argv) window = MainWindow(project_path) app.aboutToQuit.connect(window.cleanup) diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py index 002d990..d12a49a 100644 --- a/pySimBlocks/gui/graphics/block_item.py +++ b/pySimBlocks/gui/graphics/block_item.py @@ -33,6 +33,15 @@ class BlockItem(QGraphicsRectItem): + """Render and interact with a block instance on the diagram scene. + + Attributes: + view: Diagram view owning this graphics item. + instance: Block instance represented by the item. + orientation: Display orientation of the block. + port_items: Visual port items attached to the block. + """ + WIDTH = 120 HEIGHT = 60 MIN_WIDTH = 40 @@ -48,6 +57,17 @@ def __init__(self, view: "DiagramView", layout: dict | None = None, ): + """Initialize a block item. + + Args: + instance: Block instance represented by this item. + pos: Initial scene position. + view: Diagram view owning the item. + layout: Optional persisted layout properties. + + Raises: + None. + """ layout = layout or {} width = layout.get("width", self.WIDTH) height = layout.get("height", self.HEIGHT) @@ -82,12 +102,20 @@ def __init__(self, # Public Methods # -------------------------------------------------------------------------- def get_port_item(self, name:str) -> PortItem | None: + """Return the visual port item matching the given port name. + + Args: + name: Port name to look up. + + Returns: + Matching port item, or None if not found. + """ for port in self.port_items: if port.instance.name == name: return port - # -------------------------------------------------------------- def refresh_ports(self): + """Synchronize visual ports with the current block instance ports.""" for item in list(self.port_items): if item.instance not in self.instance.ports: @@ -106,31 +134,43 @@ def refresh_ports(self): item.update_display_as() self.view.on_block_ports_refreshed(self) - # -------------------------------------------------------------- def toggle_orientation(self): + """Flip the block orientation and relayout its ports.""" self.orientation = "flipped" if self.orientation == "normal" else "normal" self._layout_ports() self.view.on_block_moved(self) self.update() - # -------------------------------------------------------------------------- - # Visual Methods - # -------------------------------------------------------------------------- def boundingRect(self) -> QRectF: + """Return the item bounds including resize-handle hit areas. + + Returns: + Bounding rectangle used for painting and interaction. + """ half = self.SELECTION_HANDLE_HIT_SIZE / 2 return self.rect().adjusted(-half, -half, half, half) - # -------------------------------------------------------------- def shape(self) -> QPainterPath: + """Return the selectable shape including resize handles. + + Returns: + Painter path used for hit testing. + """ path = QPainterPath() path.addRect(self.rect()) for rect in self._handle_hit_rects().values(): path.addRect(rect) return path - # -------------------------------------------------------------- def paint(self, painter, option, widget=None): + """Paint the block body, label, and resize handles when selected. + + Args: + painter: Painter used to render the item. + option: Style option describing the current paint state. + widget: Optional target widget. + """ t = self.view.theme selected = bool(option.state & QStyle.State_Selected) @@ -163,10 +203,12 @@ def paint(self, painter, option, widget=None): for x, y in corners: painter.drawRect(x - half, y - half, self.SELECTION_HANDLE_SIZE, self.SELECTION_HANDLE_SIZE) - # -------------------------------------------------------------------------- - # Event Methods - # -------------------------------------------------------------------------- def mousePressEvent(self, event): + """Start resize interaction when a selected handle is pressed. + + Args: + event: Qt mouse-press event. + """ if self.isSelected(): handle = self._handle_at(event.pos()) if handle is not None: @@ -180,8 +222,12 @@ def mousePressEvent(self, event): super().mousePressEvent(event) - # -------------------------------------------------------------- def mouseMoveEvent(self, event): + """Resize or move the block in response to mouse movement. + + Args: + event: Qt mouse-move event. + """ if self._resize_handle and self._resize_start_mouse and self._resize_start_pos: delta = event.scenePos() - self._resize_start_mouse dx = round(delta.x() / self.GRID_DX) * self.GRID_DX @@ -216,22 +262,39 @@ def mouseMoveEvent(self, event): super().mouseMoveEvent(event) - # -------------------------------------------------------------- def mouseReleaseEvent(self, event): + """End any active resize interaction. + + Args: + event: Qt mouse-release event. + """ self._resize_handle = None self._resize_start_mouse = None self._resize_start_pos = None super().mouseReleaseEvent(event) - # -------------------------------------------------------------- def mouseDoubleClickEvent(self, event): + """Open the block configuration dialog on double click. + + Args: + event: Qt mouse double-click event. + """ dialog = BlockDialog(self, readonly=False) dialog.exec() self.update() event.accept() - # -------------------------------------------------------------- def itemChange(self, change, value): + """Snap movement to the grid and notify the view when position changes. + + Args: + change: Item change identifier. + value: Proposed new value for the change. + + Returns: + Adjusted change value when snapping is needed, otherwise the base + implementation result. + """ if change == QGraphicsItem.ItemPositionChange and self.scene(): x = round(value.x() / self.GRID_DX) * self.GRID_DX y = round(value.y() / self.GRID_DY) * self.GRID_DY @@ -246,6 +309,7 @@ def itemChange(self, change, value): # Private Methods # -------------------------------------------------------------------------- def _handle_hit_rects(self) -> dict[str, QRectF]: + """Return enlarged hit rectangles for the resize handles.""" half = self.SELECTION_HANDLE_HIT_SIZE / 2 r = self.rect() return { @@ -255,15 +319,15 @@ def _handle_hit_rects(self) -> dict[str, QRectF]: "br": QRectF(r.right() - half, r.bottom() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE), } - # -------------------------------------------------------------- def _handle_at(self, local_pos: QPointF) -> str | None: + """Return the resize handle name located under the given position.""" for name, rect in self._handle_hit_rects().items(): if rect.contains(local_pos): return name return None - # -------------------------------------------------------------- def _layout_ports(self): + """Place input and output ports on the correct block sides.""" inputs = [p for p in self.port_items if p.is_input] outputs = [p for p in self.port_items if not p.is_input] @@ -277,8 +341,8 @@ def _layout_ports(self): self._layout_side(inputs, x=width) self._layout_side(outputs, x=0) - # -------------------------------------------------------------- def _layout_side(self, ports, x): + """Evenly distribute a list of ports along one block side.""" if not ports: return diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 4ba9221..42c5f18 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -27,12 +27,38 @@ class OrthogonalRoute: + """Store routed connection points and the segment being dragged. + + Attributes: + points: Ordered route points in scene coordinates. + dragged_index: Index of the segment currently being dragged. + """ + def __init__(self, points: list[QPointF]): + """Initialize a routed polyline. + + Args: + points: Ordered route points in scene coordinates. + + Raises: + None. + """ self.points = points self.dragged_index: int | None = None class ConnectionItem(QGraphicsPathItem): + """Render and interact with a connection between two ports. + + Attributes: + src_port: Source port item of the connection. + dst_port: Destination port item of the connection. + instance: Connection model represented by this item. + is_temporary: Whether the connection is currently incomplete. + is_manual: Whether the route was manually adjusted. + route: Current orthogonal route definition. + """ + OFFSET = 8 MARGIN = 12 DETOUR = 8 @@ -44,6 +70,17 @@ def __init__(self, dst_port: PortItem | None, instance: ConnectionInstance, points: list[QPointF] | None = None): + """Initialize a connection item. + + Args: + src_port: Source port item, if already known. + dst_port: Destination port item, if already known. + instance: Connection model represented by this item. + points: Optional persisted route points. + + Raises: + ValueError: If both ports are missing. + """ super().__init__() if src_port is None and dst_port is None: @@ -78,9 +115,10 @@ def __init__(self, self.update_position() # -------------------------------------------------------------------------- - # Position methods + # Public Methods # -------------------------------------------------------------------------- def update_position(self): + """Recompute the displayed route from the current port positions.""" if self.is_temporary: return @@ -96,31 +134,125 @@ def update_position(self): self.route = OrthogonalRoute(pts) self._apply_route(self.route.points) - # ------------------------------------------------------------------ def update_temp_position(self, scene_pos: QPointF): + """Update the temporary route endpoint while dragging. + + Args: + scene_pos: Current mouse position in scene coordinates. + """ p1 = self._valid_port.connection_anchor() pts = [p1, scene_pos] self._apply_route(pts) - # -------------------------------------------------------------------------- - # Routing methods - # -------------------------------------------------------------------------- def apply_manual_route(self, points: list[QPointF]): + """Apply a persisted manual route to the connection. + + Args: + points: Route points in scene coordinates. + """ self.route = OrthogonalRoute(points) self.is_manual = True self._apply_route(self.route.points) - # ------------------------------------------------------------------ def invalidate_manual_route(self): - """ - Called when a connected block moves: manual routing is discarded and - next update_position() will fully recompute the orthogonal route. - """ + """Discard any manual route so the next update recomputes it.""" self.is_manual = False self.route = None - # ------------------------------------------------------------------ + def segment_at(self, scene_pos: QPointF) -> int | None: + """Return the route segment index located near the given scene point. + + Args: + scene_pos: Scene position to test. + + Returns: + Index of the matching segment, or None if none is close enough. + """ + if not self.route: + return None + + pts = self.route.points + for i in range(len(pts) - 1): + a, b = pts[i], pts[i + 1] + + if a.x() == b.x(): # vertical + if abs(scene_pos.x() - a.x()) < self.PICK_TOL \ + and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()): + return i + + if a.y() == b.y(): # horizontal + if abs(scene_pos.y() - a.y()) < self.PICK_TOL \ + and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()): + return i + return None + + def shape(self): + """Return an enlarged hit shape so connections are easier to select. + + Returns: + Stroke path used for hit testing. + """ + stroker = QPainterPathStroker() + stroker.setWidth(6) + return stroker.createStroke(self.path()) + + def mousePressEvent(self, event): + """Start manual segment dragging when pressing a routed segment. + + Args: + event: Qt mouse-press event. + """ + idx = self.segment_at(event.scenePos()) + if idx is not None: + self.route.dragged_index = idx + self.is_manual = True + event.accept() + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + """Move the selected orthogonal segment during manual route editing. + + Args: + event: Qt mouse-move event. + """ + if not self.route or self.route.dragged_index is None: + return + + i = self.route.dragged_index + a = self.route.points[i] + b = self.route.points[i + 1] + pos = event.scenePos() + + if a.x() == b.x(): # vertical segment + x = self._snap(pos.x()) + self.route.points[i] = QPointF(x, a.y()) + self.route.points[i + 1] = QPointF(x, b.y()) + + elif a.y() == b.y(): # horizontal segment + y = self._snap(pos.y()) + self.route.points[i] = QPointF(a.x(), y) + self.route.points[i + 1] = QPointF(b.x(), y) + + self._apply_route(self.route.points) + + def mouseReleaseEvent(self, event): + """Finish manual segment dragging. + + Args: + event: Qt mouse-release event. + """ + if self.route: + self.route.dragged_index = None + super().mouseReleaseEvent(event) + + + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: + """Compute an orthogonal route between two port anchors.""" src_block = self.src_port.parent_block dst_block = self.dst_port.parent_block @@ -174,93 +306,20 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: p2_in, p2 ] - # ------------------------------------------------------------------ def _snap(self, v: float) -> float: + """Snap a scalar coordinate to the routing grid.""" return round(v / self.GRID) * self.GRID - # -------------------------------------------------------------------------- - # Path methods - # -------------------------------------------------------------------------- def _apply_route(self, points: list[QPointF]): + """Apply a route by building and setting the corresponding path.""" path = QPainterPath(points[0]) for p in points[1:]: path.lineTo(p) self.setPath(path) - # ------------------------------------------------------------------ def _path_from(self, pts: list[QPointF]) -> QPainterPath: + """Build a painter path from an ordered list of route points.""" p = QPainterPath(pts[0]) for pt in pts[1:]: p.lineTo(pt) return p - - # -------------------------------------------------------------------------- - # Interaction (segment dragging) - # -------------------------------------------------------------------------- - def segment_at(self, scene_pos: QPointF) -> int | None: - if not self.route: - return None - - pts = self.route.points - for i in range(len(pts) - 1): - a, b = pts[i], pts[i + 1] - - if a.x() == b.x(): # vertical - if abs(scene_pos.x() - a.x()) < self.PICK_TOL \ - and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()): - return i - - if a.y() == b.y(): # horizontal - if abs(scene_pos.y() - a.y()) < self.PICK_TOL \ - and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()): - return i - return None - - # -------------------------------------------------------------- - def shape(self): - """ - Override the default shape to make it easier to click on the connection. - """ - stroker = QPainterPathStroker() - stroker.setWidth(6) # zone cliquable (px) - return stroker.createStroke(self.path()) - - # -------------------------------------------------------------------------- - # Events methods - # -------------------------------------------------------------------------- - def mousePressEvent(self, event): - idx = self.segment_at(event.scenePos()) - if idx is not None: - self.route.dragged_index = idx - self.is_manual = True - event.accept() - else: - super().mousePressEvent(event) - - # ------------------------------------------------------------------ - def mouseMoveEvent(self, event): - if not self.route or self.route.dragged_index is None: - return - - i = self.route.dragged_index - a = self.route.points[i] - b = self.route.points[i + 1] - pos = event.scenePos() - - if a.x() == b.x(): # vertical segment - x = self._snap(pos.x()) - self.route.points[i] = QPointF(x, a.y()) - self.route.points[i + 1] = QPointF(x, b.y()) - - elif a.y() == b.y(): # horizontal segment - y = self._snap(pos.y()) - self.route.points[i] = QPointF(a.x(), y) - self.route.points[i + 1] = QPointF(b.x(), y) - - self._apply_route(self.route.points) - - # ------------------------------------------------------------------ - def mouseReleaseEvent(self, event): - if self.route: - self.route.dragged_index = None - super().mouseReleaseEvent(event) diff --git a/pySimBlocks/gui/graphics/port_item.py b/pySimBlocks/gui/graphics/port_item.py index e400070..45a129e 100644 --- a/pySimBlocks/gui/graphics/port_item.py +++ b/pySimBlocks/gui/graphics/port_item.py @@ -30,6 +30,14 @@ class PortItem(QGraphicsItem): + """Render and interact with a block port on the diagram. + + Attributes: + instance: Port model represented by the item. + parent_block: Block item owning the port. + label: Text label displayed next to the port. + """ + MARGIN = 4 R = 6 # radius input port L = 15 # length output port @@ -37,6 +45,15 @@ class PortItem(QGraphicsItem): RECT = QRectF(-8, -8, 15, 15) # bounding rect for both port types def __init__(self, instance: 'PortInstance', parent_block: 'BlockItem'): + """Initialize a port item. + + Args: + instance: Port model represented by the item. + parent_block: Block item owning the port. + + Raises: + None. + """ super().__init__(parent_block) self.instance = instance @@ -52,21 +69,21 @@ def __init__(self, instance: 'PortInstance', parent_block: 'BlockItem'): self.label.setFont(QFont("Sans Serif", 8)) # -------------------------------------------------------------------------- - # Properties + # Public Methods # -------------------------------------------------------------------------- + @property def is_input(self): + """Return whether this port is an input port.""" return self.instance.direction == "input" - # -------------------------------------------------------------- @property def is_on_left_side(self) -> bool: + """Return whether the port is currently placed on the left block side.""" return self.pos().x() < (self.parent_block.rect().width() * 0.5) - # -------------------------------------------------------------------------- - # Public methods - # -------------------------------------------------------------------------- def update_label_position(self): + """Position the port label according to the current side.""" label_rect = self.label.boundingRect() if self.is_on_left_side: @@ -80,12 +97,16 @@ def update_label_position(self): self.y() - label_rect.height() / 2, ) - # -------------------------------------------------------------- def update_display_as(self): + """Refresh the displayed port label text.""" self.label.setPlainText(self.instance.display_as) - # -------------------------------------------------------------- def connection_anchor(self) -> QPointF: + """Return the scene anchor point used to attach a connection. + + Returns: + Scene coordinate used as the wire anchor for this port. + """ if self.is_input: x = -self.R if self.is_on_left_side else self.R local = QPointF(x, 0) @@ -94,18 +115,33 @@ def connection_anchor(self) -> QPointF: local = QPointF(x, 0) return self.mapToScene(local) - # -------------------------------------------------------------- def is_compatible(self, other: 'PortItem'): + """Return whether this port can connect to another port. + + Args: + other: Other port item to compare against. + + Returns: + True if the ports have opposite directions. + """ return self.instance.direction != other.instance.direction - # -------------------------------------------------------------------------- - # Visuals - # -------------------------------------------------------------------------- def boundingRect(self) -> QRectF: + """Return the fixed bounding rectangle of the port. + + Returns: + Bounding rectangle used for painting and hit testing. + """ return self.RECT - # -------------------------------------------------------------- def paint(self, painter, option, widget=None): + """Paint the port as a circle or triangle depending on its direction. + + Args: + painter: Painter used to render the item. + option: Style option describing the current paint state. + widget: Optional target widget. + """ painter.setRenderHint(QPainter.Antialiasing) fill = self.t.port_in if self.is_input else self.t.port_out @@ -124,8 +160,12 @@ def paint(self, painter, option, widget=None): path.closeSubpath() painter.drawPath(path) - # -------------------------------------------------------------- def shape(self): + """Return the hit-test shape of the port. + + Returns: + Painter path matching the painted port shape. + """ path = QPainterPath() if self.is_input: @@ -139,15 +179,25 @@ def shape(self): return path - # -------------------------------------------------------------------------- - # Event handlers - # -------------------------------------------------------------------------- def mousePressEvent(self, event): + """Start a connection drag from this port. + + Args: + event: Qt mouse-press event. + """ self.parent_block.view.create_connection_event(self) event.accept() - # -------------------------------------------------------------- def itemChange(self, change, value): + """Update the label position when the port scene position changes. + + Args: + change: Item change identifier. + value: Proposed new value for the change. + + Returns: + Base implementation result. + """ if change == QGraphicsItem.ItemScenePositionHasChanged: self.update_label_position() return super().itemChange(change, value) diff --git a/pySimBlocks/gui/graphics/theme.py b/pySimBlocks/gui/graphics/theme.py index 0f491d3..ad2428d 100644 --- a/pySimBlocks/gui/graphics/theme.py +++ b/pySimBlocks/gui/graphics/theme.py @@ -25,6 +25,7 @@ def _luma(c: QColor) -> float: + """Return the perceived luminance of a color.""" r, g, b, _ = c.getRgb() return 0.2126 * r + 0.7152 * g + 0.0722 * b @@ -58,23 +59,39 @@ def _separate_bg(block: QColor, scene: QColor, delta: float = 35.0) -> QColor: @dataclass(frozen=True) class Theme: + """Store the GUI color palette used by the graphics layer.""" + #: Scene background color. scene_bg: QColor + #: Default block background color. block_bg: QColor + #: Selected block background color. block_bg_selected: QColor + #: Default block border color. block_border: QColor + #: Selected block border color. block_border_selected: QColor + #: Default text color. text: QColor + #: Selected text color. text_selected: QColor + #: Connection wire color. wire: QColor + #: Input port color. port_in: QColor + #: Output port color. port_out: QColor def make_theme() -> Theme: + """Build a theme derived from the current Qt application palette. + + Returns: + Theme adapted to the current light or dark palette. + """ pal = QApplication.palette() scene_bg = pal.color(QPalette.Window) diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py index 9254d67..21acdcd 100644 --- a/pySimBlocks/gui/main_window.py +++ b/pySimBlocks/gui/main_window.py @@ -18,8 +18,10 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path -from typing import Dict, List +from typing import List from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QKeySequence @@ -40,7 +42,30 @@ class MainWindow(QMainWindow): + """Main application window for the pySimBlocks GUI editor. + + Assembles the block library panel, diagram canvas, and toolbar. Manages + project load/save/run operations and tracks unsaved changes. + + Attributes: + loader: Service used to load a project from YAML. + saver: Service used to save a project to YAML. + runner: Service used to launch a simulation. + block_registry: Registry mapping category → block type → BlockMeta. + project_state: Shared mutable state of the currently open project. + view: The diagram canvas widget. + project_controller: Controller coordinating model and view mutations. + blocks: Block-library side panel widget. + toolbar: Toolbar widget with run/save actions. + """ + def __init__(self, project_path: Path): + """Initialize the MainWindow and open the project at ``project_path``. + + Args: + project_path: Path to the project directory. If a ``project.yaml`` + file is found inside it, the project is loaded automatically. + """ super().__init__() self.loader = ProjectLoaderYaml() @@ -81,72 +106,116 @@ def __init__(self, project_path: Path): self.quit_action.triggered.connect(self.close) self.addAction(self.quit_action) - # Ensure keyboard shortcuts (e.g. Space) work immediately on startup. QTimer.singleShot(0, self.view.setFocus) - # -------------------------------------------------------------------------- + + # -------------------------------------------------------------------------- # Registry - # -------------------------------------------------------------------------- + # -------------------------------------------------------------------------- + def get_categories(self) -> List[str]: + """Return the sorted list of block categories from the registry. + + Returns: + Sorted list of category name strings. + """ return sorted(self.block_registry.keys()) - # ------------------------------------------------------------------ def get_blocks(self, category: str) -> List[str]: + """Return the sorted list of block type names within a category. + + Args: + category: Category name to look up. + + Returns: + Sorted list of block type name strings. + """ return sorted(self.block_registry.get(category, {}).keys()) - # ------------------------------------------------------------------ def resolve_block_meta(self, category: str, block_type: str) -> BlockMeta: + """Return the :class:`BlockMeta` for a given category and block type. + + Args: + category: Category name of the block. + block_type: Type name of the block within the category. + + Returns: + The :class:`BlockMeta` descriptor for the requested block. + """ return self.block_registry[category][block_type] - # -------------------------------------------------------------------------- - # Auto Load - # -------------------------------------------------------------------------- + + # -------------------------------------------------------------------------- + # Auto load + # -------------------------------------------------------------------------- + def auto_load_detection(self, project_path: Path) -> bool: + """Return True if a recognisable project file is found in ``project_path``. + + Args: + project_path: Directory to search for a project file. + + Returns: + True if ``project.yaml`` exists in the directory, False otherwise. + """ project_yaml = self._auto_detect_yaml(project_path, ["project.yaml"]) return project_yaml is not None - # ------------------------------------------------------------------ def _auto_detect_yaml(self, project_path: Path, names: list[str]) -> str | None: + """Return the path of the first matching file in the project directory.""" for name in names: path = project_path / name if path.is_file(): return str(path) return None - # -------------------------------------------------------------------------- - # Project Management - # -------------------------------------------------------------------------- - def _on_save(self): - if not self.project_controller.is_dirty: - return - self.saver.save(self.project_controller.project_state, self.project_controller.view.block_items) - self.project_controller.clear_dirty() - # ------------------------------------------------------------------ - def update_window_title(self): + # -------------------------------------------------------------------------- + # Project management + # -------------------------------------------------------------------------- + + def update_window_title(self) -> None: + """Refresh the window title to reflect the project name and dirty state.""" path = self.project_state.directory_path project_name = path.name if path else "Untitled" star = "*" if self.project_controller.is_dirty else "" self.setWindowTitle(f"{project_name}{star} – pySimBlocks") - # ------------------------------------------------------------------ - def on_project_loaded(self, project_path: Path): + def on_project_loaded(self, project_path: Path) -> None: + """Refresh the window title after a project has been loaded. + + Args: + project_path: Path to the newly loaded project directory. + """ self.update_window_title() - # ------------------------------------------------------------------ - def cleanup(self): + def cleanup(self) -> None: + """Remove any runtime-generated project YAML files on exit.""" cleanup_runtime_project_yaml(self.project_state.directory_path) - # ------------------------------------------------------------------ - def closeEvent(self, event): + def closeEvent(self, event) -> None: + """Intercept the close event to prompt the user about unsaved changes. + + Args: + event: Qt close event. + """ if self.confirm_discard_or_save("closing"): self.cleanup() event.accept() else: event.ignore() - # ------------------------------------------------------------------ def confirm_discard_or_save(self, action_name: str) -> bool: + """Show an unsaved-changes dialog if the project is dirty. + + Args: + action_name: Human-readable name of the triggering action (e.g. + ``'closing'``), displayed in the dialog message. + + Returns: + True if the action should proceed (user saved or discarded + changes), False if the user cancelled. + """ if not self.project_controller.is_dirty: return True @@ -160,3 +229,10 @@ def confirm_discard_or_save(self, action_name: str) -> bool: return True else: return False + + def _on_save(self) -> None: + """Save the project if there are unsaved changes.""" + if not self.project_controller.is_dirty: + return + self.saver.save(self.project_controller.project_state, self.project_controller.view.block_items) + self.project_controller.clear_dirty() diff --git a/pySimBlocks/gui/models/block_instance.py b/pySimBlocks/gui/models/block_instance.py index 95ed7d4..89f4968 100644 --- a/pySimBlocks/gui/models/block_instance.py +++ b/pySimBlocks/gui/models/block_instance.py @@ -34,16 +34,31 @@ class BlockInstance: + """Represent a mutable GUI-side instance of a block. + + Attributes: + uid: Unique identifier for this instance. + meta: Immutable block metadata definition. + name: Editable block instance name. + parameters: Current parameter values for the instance. + ports: Resolved input and output ports for the instance. """ - GUI-side mutable instance of a block. - - References an immutable BlockMeta - - Stores instance-level data (name, parameters) - - Used by BlockItem and BlockDialog - """ + + # -------------------------------------------------------------------------- + # Class Methods + # -------------------------------------------------------------------------- @classmethod def copy(cls, block: Self) -> Self: + """Create a shallow copy of a block instance. + + Args: + block: Block instance to copy. + + Returns: + Copied block instance with duplicated parameters. + """ cpy = BlockInstance(block.meta) cpy.name = block.name cpy.parameters = block.parameters.copy() @@ -51,35 +66,41 @@ def copy(cls, block: Self) -> Self: def __init__(self, meta: 'BlockMeta'): + """Initialize a block instance from metadata. + + Args: + meta: Block metadata definition. + + Raises: + None. + """ self.uid: str = uuid.uuid4().hex self.meta = meta self.name: str = meta.name self.parameters: Dict[str, Any] = self._init_parameters() self.ports: List[PortInstance] = [] - def _init_parameters(self) -> dict[str, Any]: - """ - Initialize instance parameters from metadata. - """ - params = {} - - for p in self.meta.parameters: - params[p.name] = p.default if p.autofill else None - return params + # --- Public methods --- def update_params(self, params: dict[str, Any]): + """Update existing parameter values from a mapping. + + Args: + params: Parameter values keyed by parameter name. + """ for k, v in params.items(): if k in self.parameters: self.parameters[k] = v def resolve_ports(self) -> None: + """Rebuild ports while preserving existing instances when possible.""" new_ports = self.meta.build_ports(self) if not self.ports: self.ports = new_ports - return + return old_inputs = [p for p in self.ports if p.direction == "input"] old_outputs = [p for p in self.ports if p.direction == "output"] @@ -107,8 +128,25 @@ def resolve_ports(self) -> None: self.ports = updated_ports def active_parameters(self) -> dict[str, Any]: + """Return only the parameters active under the current configuration. + + Returns: + Active parameters keyed by parameter name. + """ return { k: v for k, v in self.parameters.items() if self.meta.is_parameter_active(k, self.parameters) } + + + # --- Private methods --- + + def _init_parameters(self) -> dict[str, Any]: + """Initialize parameter values from metadata defaults.""" + params = {} + + for p in self.meta.parameters: + params[p.name] = p.default if p.autofill else None + + return params diff --git a/pySimBlocks/gui/models/connection_instance.py b/pySimBlocks/gui/models/connection_instance.py index 03d1fc4..b28435c 100644 --- a/pySimBlocks/gui/models/connection_instance.py +++ b/pySimBlocks/gui/models/connection_instance.py @@ -25,19 +25,56 @@ from pySimBlocks.gui.project_controller import BlockInstance class ConnectionInstance: + """Represent a connection between two GUI ports. + + Attributes: + src_port: Source port of the connection. + dst_port: Destination port of the connection. + """ + def __init__( self, src_port: "PortInstance", dst_port: "PortInstance", ): + """Initialize a connection instance. + + Args: + src_port: Source port of the connection. + dst_port: Destination port of the connection. + + Raises: + None. + """ self.src_port = src_port self.dst_port = dst_port + + # --- Public methods --- + def src_block(self) -> "BlockInstance": + """Return the source block of the connection. + + Returns: + Block owning the source port. + """ return self.src_port.block def dst_block(self) -> "BlockInstance": + """Return the destination block of the connection. + + Returns: + Block owning the destination port. + """ return self.dst_port.block def is_block_involved(self, block: "BlockInstance") -> bool: + """Return whether the given block participates in the connection. + + Args: + block: Block instance to test. + + Returns: + True if the block owns either connection endpoint. + """ return block in (self.src_port.block, self.dst_port.block) diff --git a/pySimBlocks/gui/models/port_instance.py b/pySimBlocks/gui/models/port_instance.py index b818e80..8ca455c 100644 --- a/pySimBlocks/gui/models/port_instance.py +++ b/pySimBlocks/gui/models/port_instance.py @@ -27,6 +27,15 @@ from pySimBlocks.gui.project_controller import BlockInstance class PortInstance: + """Represent a GUI port bound to a block instance. + + Attributes: + name: Internal port name. + display_as: Label shown in the GUI. + direction: Port direction, either input or output. + block: Owning block instance. + """ + def __init__( self, name: str, @@ -34,19 +43,43 @@ def __init__( direction: Literal['input', 'output'], block: "BlockInstance" ): + """Initialize a port instance. + + Args: + name: Internal port name. + display_as: Label shown in the GUI. + direction: Port direction. + block: Owning block instance. + + Raises: + None. + """ self.name = name self.display_as = display_as self.direction = direction self.block = block + + # --- Public methods --- + def is_compatible(self, other: "PortInstance"): + """Return whether this port can connect to another port. + + Args: + other: Port to compare against. + + Returns: + True if the ports have opposite directions. + """ return self.direction != other.direction def can_accept_connection(self, connections: list["ConnectionInstance"]) -> bool: - """ - Check whether this port can accept a new connection. + """Return whether this port can accept one more connection. + + Args: + connections: Existing connections currently attached to this port. - The `connections` list is expected to contain all and only the connections - already linked to this PortInstance. + Returns: + True if the port can accept an additional connection. """ return self.direction == "output" or not connections diff --git a/pySimBlocks/gui/models/project_simulation_params.py b/pySimBlocks/gui/models/project_simulation_params.py index 97008b2..8f7251c 100644 --- a/pySimBlocks/gui/models/project_simulation_params.py +++ b/pySimBlocks/gui/models/project_simulation_params.py @@ -19,6 +19,14 @@ # ****************************************************************************** class ProjectSimulationParams: + """Store simulation parameters for a GUI project. + + Attributes: + dt: Simulation timestep in seconds. + T: Simulation duration in seconds. + solver: Solver identifier. + clock: Clock mode identifier. + """ DEFAULT_DT = 0.1 DEFAULT_T = 10. @@ -32,18 +40,38 @@ def __init__( solver: str = DEFAULT_SOLVER, clock: str = DEFAULT_CLOCK ): + """Initialize project simulation parameters. + + Args: + dt: Simulation timestep in seconds. + T: Simulation duration in seconds. + solver: Solver identifier. + clock: Clock mode identifier. + + Raises: + None. + """ self.dt = dt self.T = T self.solver = solver self.clock = clock + + # --- Public methods --- + def load_from_dict(self, params: dict) -> None: + """Load simulation parameters from a mapping. + + Args: + params: Mapping containing simulation parameter overrides. + """ self.dt = params.get("dt", self.dt) self.T = params.get("T", self.T) self.solver = params.get("solver", self.solver) self.clock = params.get("clock", self.clock) def clear(self) -> None: + """Reset all simulation parameters to their defaults.""" self.dt = self.DEFAULT_DT self.T = self.DEFAULT_T self.solver = self.DEFAULT_SOLVER diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index 949e924..aaa04b0 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -25,7 +25,28 @@ from pySimBlocks.gui.models.project_simulation_params import ProjectSimulationParams class ProjectState: + """Store the editable state of a GUI project. + + Attributes: + blocks: Block instances currently present in the project. + connections: Connections currently present in the project. + simulation: Simulation parameter set for the project. + external: Optional external runtime path or identifier. + directory_path: Project directory on disk. + logging: Signals selected for logging. + logs: Last simulation logs. + plots: Plot configurations defined for the project. + """ + def __init__(self, directory_path: Path): + """Initialize an empty project state. + + Args: + directory_path: Project directory on disk. + + Raises: + None. + """ self.blocks: list[BlockInstance] = [] self.connections: list[ConnectionInstance] = [] self.simulation = ProjectSimulationParams() @@ -36,7 +57,10 @@ def __init__(self, directory_path: Path): self.plots: list[dict[str, str | list[str]]] = [] + # --- Public methods --- + def clear(self): + """Reset blocks, connections, logs, plots, and simulation settings.""" self.blocks.clear() self.connections.clear() @@ -49,52 +73,98 @@ def clear(self): self.external = None def load_simulation(self, sim_data: dict, external = None): + """Load simulation settings into the project state. + + Args: + sim_data: Serialized simulation settings. + external: Optional external runtime value. + """ self.simulation.load_from_dict(sim_data) if external: self.external = external - # -------------------------------------------------------------------------- - # Block management - # -------------------------------------------------------------------------- def get_block(self, name:str): + """Return the block with the given name if it exists. + + Args: + name: Block instance name. + + Returns: + Matching block instance, or None if not found. + """ for block in self.blocks: if name == block.name: return block def add_block(self, block_instance: BlockInstance): + """Add a block instance to the project. + + Args: + block_instance: Block instance to add. + """ self.blocks.append(block_instance) def remove_block(self, block_instance: BlockInstance): + """Remove a block instance from the project if present. + + Args: + block_instance: Block instance to remove. + """ if block_instance in self.blocks: self.blocks.remove(block_instance) - # -------------------------------------------------------------------------- - # Connection management - # -------------------------------------------------------------------------- def add_connection(self, conn: ConnectionInstance): + """Add a connection instance to the project. + + Args: + conn: Connection instance to add. + """ self.connections.append(conn) def remove_connection(self, conn: ConnectionInstance): + """Remove a connection instance from the project if present. + + Args: + conn: Connection instance to remove. + """ if conn in self.connections: self.connections.remove(conn) def get_connections_of_block(self, block_instance: BlockInstance) -> list[ConnectionInstance]: + """Return all connections touching the given block. + + Args: + block_instance: Block instance to inspect. + + Returns: + Connections where the block is either source or destination. + """ return [ c for c in self.connections if block_instance is c.src_block() or block_instance is c.dst_block() ] def get_connections_of_port(self, port_instance: PortInstance) -> list[ConnectionInstance]: + """Return all connections attached to the given port. + + Args: + port_instance: Port instance to inspect. + + Returns: + Connections where the port is either source or destination. + """ return [ c for c in self.connections if port_instance is c.src_port or port_instance is c.dst_port ] - # -------------------------------------------------------------------------- - # Signals - # -------------------------------------------------------------------------- def get_output_signals(self) -> list[str]: + """Return all output signal paths currently available in the project. + + Returns: + Output signal identifiers in ``Block.outputs.port`` format. + """ signals = [] for block in self.blocks: @@ -105,6 +175,11 @@ def get_output_signals(self) -> list[str]: return signals def can_plot(self) -> tuple[bool, str]: + """Return whether plot generation is currently possible. + + Returns: + Tuple containing the availability flag and the reason message. + """ if not bool(self.logs): return False, "Simulation has not been done.\nPlease run fist." diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 5988ebd..a1cad85 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -18,7 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** -import copy +from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING, Any, Callable @@ -29,7 +30,6 @@ ConnectionInstance, PortInstance, ProjectState, - port_instance, ) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.blocks.block_meta import BlockMeta @@ -40,14 +40,37 @@ class ProjectController(QObject): + """Controller coordinating all mutations to the project model and diagram view. + + Acts as the single point of truth for block and connection lifecycle + operations, dirty-state tracking, plot management, and simulation parameter + updates. + + Attributes: + project_state: Shared mutable state of the open project. + view: The diagram canvas widget. + resolve_block_meta: Callable returning :class:`BlockMeta` for a given + category and block type. + is_dirty: True if there are unsaved changes. + """ + #: Signal emitted with the new dirty flag value whenever the unsaved-changes state changes. dirty_changed: Signal = Signal(bool) - def __init__(self, - project_state: ProjectState, - view: DiagramView, - resolve_block_meta: Callable[[str, str], BlockMeta] + def __init__( + self, + project_state: ProjectState, + view: DiagramView, + resolve_block_meta: Callable[[str, str], BlockMeta], ): + """Initialize the ProjectController. + + Args: + project_state: Shared project state to read and mutate. + view: The diagram view to keep in sync with the model. + resolve_block_meta: Callable returning :class:`BlockMeta` for a + given ``(category, block_type)`` pair. + """ super().__init__() self.project_state = project_state self.resolve_block_meta = resolve_block_meta @@ -55,34 +78,51 @@ def __init__(self, self.is_dirty: bool = False + # -------------------------------------------------------------------------- - # Blocks methods + # Block methods # -------------------------------------------------------------------------- - def add_block(self, category: str, - block_type: str, - block_layout: dict | None = None) -> BlockInstance: + + def add_block( + self, + category: str, + block_type: str, + block_layout: dict | None = None, + ) -> BlockInstance: + """Create and add a new block of the given type to the project. + + Args: + category: Category name of the block. + block_type: Type name of the block within the category. + block_layout: Optional dict with position/size hints for the view. + + Returns: + The newly created :class:`BlockInstance`. + """ block_meta = self.resolve_block_meta(category, block_type) block_instance = BlockInstance(block_meta) return self._add_block(block_instance, block_layout) - # ------------------------------------------------------------------ def add_copy_block(self, block_instance: BlockInstance) -> BlockInstance: + """Add a copy of an existing block to the project. + + Args: + block_instance: The block to copy. + + Returns: + The newly created copy as a :class:`BlockInstance`. + """ copy = BlockInstance.copy(block_instance) return self._add_block(copy) - # ------------------------------------------------------------------ - def _add_block(self, block_instance: BlockInstance, - block_layout: dict | None = None) -> BlockInstance: - self.make_dirty() - block_instance.name = self.make_unique_name(block_instance.name) - block_instance.resolve_ports() - self.project_state.add_block(block_instance) - self.view.add_block(block_instance, block_layout) - - return block_instance + def rename_block(self, block_instance: BlockInstance, new_name: str) -> None: + """Rename a block and update all references in logging and plot signals. - # ------------------------------------------------------------------ - def rename_block(self, block_instance: BlockInstance, new_name: str): + Args: + block_instance: The block to rename. + new_name: Desired new name. A unique suffix is appended if the name + is already taken. + """ old_name = block_instance.name if old_name == new_name: @@ -108,9 +148,14 @@ def rename_block(self, block_instance: BlockInstance, new_name: str): for s in plot["signals"] ] - # ------------------------------------------------------------------ - def update_block_param(self, block_instance: BlockInstance, params: dict[str, Any]): - + def update_block_param(self, block_instance: BlockInstance, params: dict[str, Any]) -> None: + """Apply new parameter values to a block, refreshing ports and connections as needed. + + Args: + block_instance: The block to update. + params: New parameter dict. If a ``'name'`` key is present the + block is also renamed. + """ self.rename_block(block_instance, params.pop("name", block_instance.name)) if params == block_instance.parameters: @@ -122,15 +167,17 @@ def update_block_param(self, block_instance: BlockInstance, params: dict[str, An self.view.refresh_block_port(block_instance) self.make_dirty() - # ------------------------------------------------------------------ - def remove_block(self, block_instance: BlockInstance): + def remove_block(self, block_instance: BlockInstance) -> None: + """Remove a block, its connections, and its signals from the project. + + Args: + block_instance: The block to remove. + """ self.make_dirty() - # remove connections for connection in self.project_state.get_connections_of_block(block_instance): self.remove_connection(connection) - # delete all outputs signal from logging removed_signals = [ f"{block_instance.name}.outputs.{p.name}" for p in block_instance.ports if p.direction == "output" @@ -141,21 +188,25 @@ def remove_block(self, block_instance: BlockInstance): ] self.set_logged_signals(remaining_signals) - # remove signals from plots and delete empty plot for i in reversed(range(len(self.project_state.plots))): plot = self.project_state.plots[i] - # Keep only signals that are not removed plot["signals"] = [s for s in plot["signals"] if s not in removed_signals] - # Delete the plot if it has no signals left if not plot["signals"]: self.delete_plot(i) - # remove block self.project_state.remove_block(block_instance) self.view.remove_block(block_instance) - # ------------------------------------------------------------------ def make_unique_name(self, base_name: str) -> str: + """Return ``base_name`` or a suffixed variant that is unique across all blocks. + + Args: + base_name: Desired block name. + + Returns: + ``base_name`` if available, otherwise ``base_name_N`` for the + smallest N that is not already taken. + """ existing = {b.name for b in self.project_state.blocks} if base_name not in existing: @@ -167,8 +218,17 @@ def make_unique_name(self, base_name: str) -> str: return f"{base_name}_{i}" - # ------------------------------------------------------------------ def is_name_available(self, name: str, current=None) -> bool: + """Return True if ``name`` is not already used by another block. + + Args: + name: Name to check for availability. + current: Block instance to exclude from the check (e.g. the block + being renamed). + + Returns: + True if the name is free, False if it is taken by another block. + """ for b in self.project_state.blocks: if b is current: continue @@ -176,14 +236,28 @@ def is_name_available(self, name: str, current=None) -> bool: return False return True + # -------------------------------------------------------------------------- - # connection methods + # Connection methods # -------------------------------------------------------------------------- - def add_connection(self, - port1: PortInstance, - port2: PortInstance, - points: list[QPointF] | None = None): + def add_connection( + self, + port1: PortInstance, + port2: PortInstance, + points: list[QPointF] | None = None, + ) -> None: + """Create a connection between two ports if compatible. + + The method silently returns without creating a connection if the ports + are not compatible or if the destination port cannot accept another + connection. + + Args: + port1: First port (output or input). + port2: Second port (input or output). + points: Optional list of intermediate waypoints for the wire. + """ if not port1.is_compatible(port2): return @@ -202,45 +276,45 @@ def add_connection(self, self.view.add_connection(connection_instance, points) self.make_dirty() - # ------------------------------------------------------------------ - - def _remove_connection_if_port_disapear(self, block_instance: BlockInstance) -> None: - - for connection in self.project_state.get_connections_of_block(block_instance): - - src_exists = connection.src_port in connection.src_block().ports - dst_exists = connection.dst_port in connection.dst_block().ports - if not (src_exists and dst_exists): - self.remove_connection(connection) - - # ------------------------------------------------------------------ - def remove_connection(self, connection: ConnectionInstance): + def remove_connection(self, connection: ConnectionInstance) -> None: + """Remove a connection from both the model and the view. + Args: + connection: The :class:`ConnectionInstance` to remove. + """ self.project_state.remove_connection(connection) self.view.remove_connection(connection) self.make_dirty() + # -------------------------------------------------------------------------- # Project methods # -------------------------------------------------------------------------- - def make_dirty(self): + + def make_dirty(self) -> None: + """Mark the project as having unsaved changes and emit :attr:`dirty_changed`.""" if not self.is_dirty: self.is_dirty = True self.dirty_changed.emit(True) - # ------------------------------------------------------------------ - def clear_dirty(self): + def clear_dirty(self) -> None: + """Clear the unsaved-changes flag and emit :attr:`dirty_changed`.""" if self.is_dirty: self.is_dirty = False self.dirty_changed.emit(False) - # ------------------------------------------------------------------ - def clear(self): + def clear(self) -> None: + """Reset the project state and diagram view to an empty state.""" self.project_state.clear() self.view.clear_scene() - # ------------------------------------------------------------------ - def update_project_param(self, new_path: Path, ext: str): + def update_project_param(self, new_path: Path, ext: str) -> None: + """Update the project directory path and external module reference. + + Args: + new_path: New project directory path. + ext: New external module path string, or ``''`` to clear it. + """ cleanup_runtime_project_yaml(self.project_state.directory_path) if new_path != self.project_state.directory_path: self.make_dirty() @@ -250,14 +324,28 @@ def update_project_param(self, new_path: Path, ext: str): self.make_dirty() self.project_state.external = None if ext == "" else ext - # ------------------------------------------------------------------ - def load_project(self, loader: 'ProjectLoader'): + def load_project(self, loader: "ProjectLoader") -> None: + """Delegate project loading to the given loader service. + + Args: + loader: A :class:`ProjectLoader` implementation that reads the + project files and populates this controller. + """ loader.load(self, self.project_state.directory_path) + # -------------------------------------------------------------------------- # Plot methods # -------------------------------------------------------------------------- + def create_plot(self, title: str, signals: list[str]) -> None: + """Append a new plot to the project configuration. + + Args: + title: Title of the plot figure. + signals: List of signal names to display in the plot. Any signal + not already logged is automatically added to the logging list. + """ self._ensure_logged(signals) self.project_state.plots.append({ "title": title, @@ -265,8 +353,15 @@ def create_plot(self, title: str, signals: list[str]) -> None: }) self.make_dirty() - # ------------------------------------------------------------------ def update_plot(self, index: int, title: str, signals: list[str]) -> None: + """Update the title and signals of an existing plot. + + Args: + index: Index of the plot in :attr:`ProjectState.plots`. + title: New title for the plot. + signals: New list of signal names. Any signal not yet logged is + automatically added. + """ self._ensure_logged(signals) plot = self.project_state.plots[index] if plot["signals"] == signals and plot["title"] == title: @@ -275,28 +370,68 @@ def update_plot(self, index: int, title: str, signals: list[str]) -> None: plot["signals"] = list(signals) self.make_dirty() - # ------------------------------------------------------------------ def delete_plot(self, index: int) -> None: + """Remove a plot by index. + + Args: + index: Index of the plot in :attr:`ProjectState.plots`. + """ del self.project_state.plots[index] self.make_dirty() - # ------------------------------------------------------------------ - def _ensure_logged(self, signals: list[str]): - for sig in signals: - if sig not in self.project_state.logging: - self.project_state.logging.append(sig) + def update_simulation_params(self, params: dict[str, float | str]) -> None: + """Apply new simulation parameters to the project state. - # ------------------------------------------------------------------ - def update_simulation_params(self, params: dict[str, float | str]): + Args: + params: Dict of simulation parameters (e.g. ``dt``, ``T``). + """ if self.project_state.simulation.__dict__ == params: return self.project_state.load_simulation(params) self.make_dirty() - # ------------------------------------------------------------------ - def set_logged_signals(self, signals: list[str]): + def set_logged_signals(self, signals: list[str]) -> None: + """Replace the logging list with ``signals``, preserving insertion order. + + Args: + signals: New list of signal names to log. Duplicates are removed + while preserving the first occurrence. + """ new_logging = list(dict.fromkeys(signals)) if set(self.project_state.logging) == set(new_logging): return self.project_state.logging = new_logging self.make_dirty() + + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + + def _add_block( + self, + block_instance: BlockInstance, + block_layout: dict | None = None, + ) -> BlockInstance: + """Register a block instance in the model and add its visual item to the view.""" + self.make_dirty() + block_instance.name = self.make_unique_name(block_instance.name) + block_instance.resolve_ports() + self.project_state.add_block(block_instance) + self.view.add_block(block_instance, block_layout) + + return block_instance + + def _remove_connection_if_port_disapear(self, block_instance: BlockInstance) -> None: + """Remove any connection whose source or destination port no longer exists.""" + for connection in self.project_state.get_connections_of_block(block_instance): + src_exists = connection.src_port in connection.src_block().ports + dst_exists = connection.dst_port in connection.dst_block().ports + if not (src_exists and dst_exists): + self.remove_connection(connection) + + def _ensure_logged(self, signals: list[str]) -> None: + """Append any signal not yet in the logging list.""" + for sig in signals: + if sig not in self.project_state.logging: + self.project_state.logging.append(sig) diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 909b365..77e13ed 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -28,13 +28,42 @@ class ProjectLoader(ABC): + """Define the interface for project loading services.""" + + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + @abstractmethod def load(self, controller: ProjectController, directory: Path): + """Load a project into the given controller. + + Args: + controller: Controller that receives the loaded project state. + directory: Project directory containing project data. + """ pass class ProjectLoaderYaml(ProjectLoader): + """Load projects from the YAML project format.""" + + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def load(self, controller: ProjectController, directory: Path): + """Load a YAML project into the given controller. + + Args: + controller: Controller that receives the loaded project state. + directory: Project directory containing ``project.yaml``. + + Raises: + ValueError: If the project file structure is invalid. + """ project_yaml = directory / "project.yaml" project_data = load_yaml_file(str(project_yaml)) @@ -59,7 +88,13 @@ def load(self, controller: ProjectController, directory: Path): controller.clear_dirty() + + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _load_simulation(self, controller: ProjectController, sim_data: dict): + """Load simulation settings into the controller project state.""" if not isinstance(sim_data, dict): sim_data = {} controller.project_state.load_simulation( @@ -72,6 +107,7 @@ def _load_blocks( diagram_data: dict, layout_blocks: dict | None = None, ): + """Create block instances and restore their layout metadata.""" positions, position_warnings = self._compute_block_positions( diagram_data, layout_blocks ) @@ -116,6 +152,7 @@ def _load_blocks( controller.view.refresh_block_port(block) def _sanitize_block_layout(self, block_layout: dict | None) -> dict: + """Filter a raw block layout mapping down to supported properties.""" if not isinstance(block_layout, dict): return {} @@ -141,6 +178,7 @@ def _load_connections( diagram_data: dict, layout_conns: dict | None, ): + """Create connections and restore any manual routing data.""" connections = diagram_data.get("connections", []) if not isinstance(connections, list): raise ValueError("'diagram.connections' must be a list.") @@ -195,19 +233,17 @@ def _load_connections( controller.add_connection(src_port, dst_port, points) def _load_logging(self, controller: ProjectController, sim_data: dict): + """Load the configured logging signal list.""" log_data = sim_data.get("logging", []) controller.project_state.logging = log_data if isinstance(log_data, list) else [] def _load_plots(self, controller: ProjectController, sim_data: dict): + """Load the configured plot definitions.""" plot_data = sim_data.get("plots", []) controller.project_state.plots = plot_data if isinstance(plot_data, list) else [] def _load_layout_data(self, gui_data: dict) -> tuple[dict, dict, list[str]]: - """ - Load layout data from: - gui.layout.blocks - gui.layout.connections - """ + """Extract block and connection layout data from GUI configuration.""" warnings = [] if not isinstance(gui_data, dict): @@ -237,6 +273,7 @@ def _compute_block_positions( diagram_data: dict, layout_blocks: dict | None, ) -> tuple[dict[str, QPointF], list[str]]: + """Compute block positions from saved layout or fallback auto-placement.""" warnings = [] positions = {} @@ -296,6 +333,7 @@ def _parse_manual_routes( diagram_data: dict, layout_connections: dict | None, ) -> tuple[dict[str, list[QPointF]], list[str]]: + """Parse manual connection routes from saved layout data.""" warnings = [] routes: dict[str, list[QPointF]] = {} diff --git a/pySimBlocks/gui/services/project_saver.py b/pySimBlocks/gui/services/project_saver.py index 8b3655c..a94c838 100644 --- a/pySimBlocks/gui/services/project_saver.py +++ b/pySimBlocks/gui/services/project_saver.py @@ -27,23 +27,53 @@ class ProjectSaver(ABC): - + """Define the interface for project persistence services.""" + + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + @abstractmethod def save(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None): + """Persist the current project state. + + Args: + project_state: Project state to save. + block_items: Optional GUI block items used to persist layout data. + """ pass @abstractmethod def export(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None): + """Export the current project state into runnable project artifacts. + + Args: + project_state: Project state to export. + block_items: Optional GUI block items used to persist layout data. + """ pass class ProjectSaverYaml(ProjectSaver): + """Save and export projects using the YAML project format.""" + + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- def save(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None ): + """Write the project YAML file to disk. + + Args: + project_state: Project state to save. + block_items: Optional GUI block items used to persist layout data. + """ save_yaml( project_state, block_items if block_items is not None else {}, @@ -54,6 +84,15 @@ def export(self, project_state: ProjectState, block_items: dict[str, BlockItem] | None = None ): + """Export the project YAML file and generated run script. + + Args: + project_state: Project state to export. + block_items: Optional GUI block items used to persist layout data. + + Raises: + ValueError: If the project directory is not defined. + """ if project_state.directory_path is None: raise ValueError("Project directory is not set.") diff --git a/pySimBlocks/gui/services/simulation_runner.py b/pySimBlocks/gui/services/simulation_runner.py index b2e552f..c866599 100644 --- a/pySimBlocks/gui/services/simulation_runner.py +++ b/pySimBlocks/gui/services/simulation_runner.py @@ -30,7 +30,22 @@ class SimulationRunner: + """Run a project simulation from the GUI service layer.""" + + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def run(self, project_state: ProjectState): + """Execute a simulation for the current project state. + + Args: + project_state: Project state to simulate. + + Returns: + Tuple containing logs, a success flag, and a status message. + """ project_dir = project_state.directory_path if project_dir is None: return ( diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index ebb9d37..68a8eaf 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -27,6 +27,14 @@ def load_yaml_file(path: str) -> dict: + """Load a YAML file and return its top-level mapping. + + Args: + path: Path to the YAML file. + + Returns: + Parsed YAML mapping, or an empty dict for an empty file. + """ with open(path, "r") as f: return yaml.safe_load(f) or {} @@ -37,10 +45,12 @@ class FlowStyleList(list): class ProjectYamlDumper(yaml.SafeDumper): + """Custom YAML dumper for pySimBlocks project files.""" pass def _repr_flow_list(dumper, data): + """Represent a list using YAML flow style.""" return dumper.represent_sequence( "tag:yaml.org,2002:seq", data, @@ -54,6 +64,7 @@ class FlowMatrix(list): def _is_matrix(obj): + """Return whether an object is a rectangular list-of-lists matrix.""" if not isinstance(obj, list): return False if not obj: @@ -65,6 +76,7 @@ def _is_matrix(obj): def _wrap_flow_matrices(obj): + """Wrap nested matrices so they are emitted using YAML flow style.""" if _is_matrix(obj): return FlowMatrix([_wrap_flow_matrices(row) for row in obj]) @@ -86,6 +98,19 @@ def dump_project_yaml( block_items: dict[str, BlockItem] | None = None, raw: dict | None = None, ) -> str: + """Serialize project data into the pySimBlocks YAML format. + + Args: + project_state: Project state to serialize when ``raw`` is not provided. + block_items: Optional GUI block items used to persist layout data. + raw: Prebuilt raw project mapping to serialize directly. + + Returns: + YAML string representation of the project. + + Raises: + ValueError: If neither ``project_state`` nor ``raw`` is provided. + """ if raw is None: if project_state is None: raise ValueError("project_state or raw must be set") @@ -104,6 +129,16 @@ def save_yaml( block_items: dict[str, BlockItem] | None = None, runtime: bool = False, ) -> None: + """Write project YAML data to disk. + + Args: + project_state: Project state to serialize. + block_items: Optional GUI block items used to persist layout data. + runtime: If True, write the runtime YAML file instead of ``project.yaml``. + + Raises: + ValueError: If the project directory is not defined. + """ directory = project_state.directory_path if directory is None: raise ValueError("project_state.directory_path must be set") @@ -115,10 +150,23 @@ def save_yaml( def runtime_project_yaml_path(project_dir: Path) -> Path: + """Return the runtime project YAML path for a project directory. + + Args: + project_dir: Project directory path. + + Returns: + Path to the runtime YAML file. + """ return project_dir / ".project.runtime.yaml" def cleanup_runtime_project_yaml(project_dir: Path | None) -> None: + """Delete the runtime project YAML file if it exists. + + Args: + project_dir: Project directory path, if available. + """ if project_dir is None: return @@ -128,6 +176,7 @@ def cleanup_runtime_project_yaml(project_dir: Path | None) -> None: def _build_simulation_section(project_state: ProjectState) -> dict: + """Build the simulation section for a project YAML document.""" simulation = project_state.simulation.__dict__.copy() if simulation.get("clock") == "internal": simulation.pop("clock", None) @@ -141,6 +190,7 @@ def _build_simulation_section(project_state: ProjectState) -> dict: def _build_blocks_section(project_state: ProjectState) -> list[dict]: + """Build the block list section for a project YAML document.""" blocks = [] for b in project_state.blocks: params = { @@ -159,6 +209,7 @@ def _build_blocks_section(project_state: ProjectState) -> list[dict]: def _build_connections_section(project_state: ProjectState) -> tuple[list[dict], dict[tuple[str, str], str]]: + """Build connection entries and their generated connection names.""" connections = [] conn_name_map: dict[tuple[str, str], str] = {} @@ -181,6 +232,7 @@ def _build_layout_section( block_items: dict[str, BlockItem], conn_name_map: dict[tuple[str, str], str], ) -> dict: + """Build the GUI layout section for a project YAML document.""" data: dict = {"blocks": {}} manual_connections = {} seen = set() @@ -230,6 +282,15 @@ def build_project_yaml( project_state: ProjectState, block_items: dict[str, BlockItem] | None = None, ) -> dict: + """Build the full raw project mapping before YAML serialization. + + Args: + project_state: Project state to serialize. + block_items: Optional GUI block items used to persist layout data. + + Returns: + Raw project mapping ready for YAML serialization. + """ block_items = block_items if block_items is not None else {} project_name = ( project_state.directory_path.name diff --git a/pySimBlocks/gui/widgets/block_list.py b/pySimBlocks/gui/widgets/block_list.py index 9a9cf37..d90d810 100644 --- a/pySimBlocks/gui/widgets/block_list.py +++ b/pySimBlocks/gui/widgets/block_list.py @@ -28,18 +28,34 @@ from pySimBlocks.gui.models.block_instance import BlockInstance class _PreviewBlock: + """Minimal preview wrapper used by the block dialog.""" + def __init__(self, instance): + """Initialize the preview wrapper.""" self.instance = instance def refresh_ports(self): + """No-op refresh hook for the preview block.""" pass class _BlockTree(QTreeWidget): + """Tree widget listing block categories and block types.""" + def __init__(self, get_categories: Callable[[], list[str]], get_blocks: Callable[[str], list[str]], resolve_block_meta: Callable[[str, str], BlockMeta]): + """Initialize the block tree. + + Args: + get_categories: Callable returning available block categories. + get_blocks: Callable returning block types for a category. + resolve_block_meta: Callable resolving block metadata from names. + + Raises: + None. + """ super().__init__() self.setHeaderHidden(True) self.setDragEnabled(True) @@ -57,7 +73,13 @@ def __init__(self, self.itemDoubleClicked.connect(self.on_item_double_clicked) + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + def startDrag(self, supportedActions): + """Start a drag operation for the currently selected block item.""" item = self.currentItem() if not item or item.childCount() > 0: return @@ -71,6 +93,7 @@ def startDrag(self, supportedActions): def on_item_double_clicked(self, item, column): + """Open a read-only preview dialog for a leaf block item.""" if item.childCount() > 0: return @@ -89,10 +112,27 @@ def on_item_double_clicked(self, item, column): class BlockList(QWidget): + """Sidebar widget listing blocks and filtering them by search text. + + Attributes: + search: Search field used to filter categories and block names. + tree: Tree widget showing available blocks grouped by category. + """ + def __init__(self, get_categories: Callable[[], list[str]], get_blocks: Callable[[str], list[str]], resolve_block_meta: Callable[[str, str], BlockMeta]): + """Initialize the block list widget. + + Args: + get_categories: Callable returning available block categories. + get_blocks: Callable returning block types for a category. + resolve_block_meta: Callable resolving block metadata from names. + + Raises: + None. + """ super().__init__() self.search = QLineEdit(self) @@ -108,7 +148,13 @@ def __init__(self, self.search.textChanged.connect(self._apply_filter) + + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + def _apply_filter(self, text: str): + """Filter visible categories and blocks using the search text.""" query = text.strip().lower() for i in range(self.tree.topLevelItemCount()): diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 6102905..cf232df 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from typing import TYPE_CHECKING, Any from PySide6.QtCore import QPointF, Qt, QTimer @@ -36,7 +38,31 @@ class DiagramView(QGraphicsView): + """Interactive Qt graphics view for the block diagram canvas. + + Handles block/connection rendering, drag-and-drop, keyboard shortcuts, + zoom, and mouse-driven wire creation. + + Attributes: + diagram_scene: The underlying QGraphicsScene. + theme: Current visual theme (colours, brushes). + pending_port: Port item waiting for a connection to be completed. + temp_connection: Temporary wire shown while dragging from a port. + copied_block: Most recently copied block, used for paste. + project_controller: Controller coordinating model mutations. + block_items: Mapping from block UID to its visual BlockItem. + connections: Mapping from ConnectionInstance to its visual ConnectionItem. + """ + def __init__(self): + """Initialize the diagram view and configure scene behavior. + + Args: + None. + + Raises: + None. + """ super().__init__() self.diagram_scene = QGraphicsScene(self) self.setScene(self.diagram_scene) @@ -55,7 +81,7 @@ def __init__(self): self.pending_port: PortItem | None = None self.temp_connection: ConnectionItem | None = None self.copied_block: BlockItem | None = None - self.project_controller: "ProjectController" | None + self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} @@ -64,95 +90,161 @@ def __init__(self): self.setDragMode(QGraphicsView.RubberBandDrag) # -------------------------------------------------------------------------- - # View methods + # Public Methods # -------------------------------------------------------------------------- - def add_block(self, block_instance: BlockInstance, - block_layout: dict[str, Any] | None = None): + + def add_block( + self, + block_instance: BlockInstance, + block_layout: dict[str, Any] | None = None, + ) -> None: + """Add a visual block item to the scene for the given block instance. + + Args: + block_instance: The block model to represent visually. + block_layout: Optional dict with position/size hints. + """ block_item = BlockItem(block_instance, self.drop_event_pos, self, block_layout) self.diagram_scene.addItem(block_item) self.block_items[block_instance.uid] = block_item - # -------------------------------------------------------------- - def refresh_block_port(self, block_instance: BlockInstance): + def refresh_block_port(self, block_instance: BlockInstance) -> None: + """Refresh the port visuals of the block item for the given instance. + + Args: + block_instance: The block whose port items should be refreshed. + """ block_item = self.get_block_item_from_instance(block_instance) if block_item: block_item.refresh_ports() - # -------------------------------------------------------------- - def remove_block(self, block_instance: BlockInstance): + def remove_block(self, block_instance: BlockInstance) -> None: + """Remove the visual block item for the given instance from the scene. + + Args: + block_instance: The block whose visual item should be removed. + """ block_item = self.block_items[block_instance.uid] self.diagram_scene.removeItem(block_item) self.block_items.pop(block_instance.uid, None) - # -------------------------------------------------------------- - def add_connection(self, - connection_instance: ConnectionInstance, - points: list[QPointF] | None = None - ): + def add_connection( + self, + connection_instance: ConnectionInstance, + points: list[QPointF] | None = None, + ) -> None: + """Add a visual wire to the scene for the given connection instance. + + Args: + connection_instance: The connection model to represent visually. + points: Optional list of intermediate waypoints for the wire. + """ src_port_item = self.get_block_item_from_instance(connection_instance.src_block()).get_port_item(connection_instance.src_port.name) dst_port_item = self.get_block_item_from_instance(connection_instance.dst_block()).get_port_item(connection_instance.dst_port.name) connection_item = ConnectionItem( - src_port_item, dst_port_item, connection_instance, points - ) + src_port_item, dst_port_item, connection_instance, points + ) self.connections[connection_instance] = connection_item self.diagram_scene.addItem(connection_item) - # -------------------------------------------------------------- - def remove_connection(self, connection_instance: ConnectionInstance): + def remove_connection(self, connection_instance: ConnectionInstance) -> None: + """Remove the visual wire for the given connection instance from the scene. + + Args: + connection_instance: The connection whose visual item should be removed. + """ connection_item = self.connections.pop(connection_instance, None) if connection_item: self.diagram_scene.removeItem(connection_item) - # -------------------------------------------------------------- def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockItem | None: + """Return the visual BlockItem for the given block instance, or None. + + Args: + block_instance: The block model to look up. + + Returns: + The corresponding :class:`BlockItem`, or ``None`` if not found. + """ return self.block_items.get(block_instance.uid) - # -------------------------------------------------------------- - def create_connection_event(self, port: PortItem): + def create_connection_event(self, port: PortItem) -> None: + """Begin a wire-drag interaction from the given port item. + + Args: + port: The port item from which the connection is being drawn. + """ if not self.pending_port: self.pending_port = port self.temp_connection = ConnectionItem(self.pending_port, None, None) self.diagram_scene.addItem(self.temp_connection) return - # -------------------------------------------------------------- - def update_block_param_event(self, block_instance: BlockInstance, params: dict[str, Any]): + def update_block_param_event(self, block_instance: BlockInstance, params: dict[str, Any]) -> None: + """Delegate a parameter update for the given block to the project controller. + + Args: + block_instance: The block to update. + params: New parameter dict to apply. + """ self.project_controller.update_block_param(block_instance, params) - # -------------------------------------------------------------- - def on_block_moved(self, block_item: BlockItem): + def on_block_moved(self, block_item: BlockItem) -> None: + """Mark the project dirty and refresh all wires connected to the moved block. + + Args: + block_item: The block item that was repositioned. + """ self.project_controller.make_dirty() for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.invalidate_manual_route() conn_item.update_position() - # -------------------------------------------------------------- - def on_block_ports_refreshed(self, block_item: BlockItem): + def on_block_ports_refreshed(self, block_item: BlockItem) -> None: + """Refresh all wire positions after the ports of a block have been updated. + + Args: + block_item: The block item whose ports were refreshed. + """ for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.update_position() - # -------------------------------------------------------------------------- - # Event handlers - # -------------------------------------------------------------------------- - def dragEnterEvent(self, event): + def dragEnterEvent(self, event) -> None: + """Accept drag events that carry text MIME data. + + Args: + event: Qt drag-enter event. + """ if event.mimeData().hasText(): event.acceptProposedAction() - # -------------------------------------------------------------- - def dragMoveEvent(self, event): + def dragMoveEvent(self, event) -> None: + """Accept proposed drag-move actions unconditionally. + + Args: + event: Qt drag-move event. + """ event.acceptProposedAction() - # -------------------------------------------------------------- - def dropEvent(self, event): + def dropEvent(self, event) -> None: + """Handle a block drop by adding the corresponding block to the project. + + Args: + event: Qt drop event carrying ``"category:block_type"`` text. + """ self.drop_event_pos = self.mapToScene(event.position().toPoint()) category, block_type = event.mimeData().text().split(":") self.project_controller.add_block(category, block_type) event.acceptProposedAction() - # -------------------------------------------------------------- - def keyPressEvent(self, event): + def keyPressEvent(self, event) -> None: + """Handle keyboard shortcuts for copy, paste, delete, zoom, rotate, and center. + + Args: + event: Qt key-press event. + """ # COPY if event.key() == Qt.Key_C and event.modifiers() & Qt.ControlModifier: selected = [i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem)] @@ -196,10 +288,12 @@ def keyPressEvent(self, event): return super().keyPressEvent(event) + def wheelEvent(self, event) -> None: + """Zoom the view when Ctrl is held, otherwise scroll normally. - - # -------------------------------------------------------------- - def wheelEvent(self, event): + Args: + event: Qt wheel event. + """ if event.modifiers() & Qt.ControlModifier: zoom_factor = 1.15 if event.angleDelta().y() > 0: @@ -210,16 +304,24 @@ def wheelEvent(self, event): else: super().wheelEvent(event) - # -------------------------------------------------------------- - def mouseMoveEvent(self, event): + def mouseMoveEvent(self, event) -> None: + """Update the temporary wire endpoint while dragging from a port. + + Args: + event: Qt mouse-move event. + """ if self.temp_connection: pos = self.mapToScene(event.position().toPoint()) self.temp_connection.update_temp_position(pos) return super().mouseMoveEvent(event) - # -------------------------------------------------------------- - def mouseReleaseEvent(self, event): + def mouseReleaseEvent(self, event) -> None: + """Complete or cancel a wire drag on mouse release. + + Args: + event: Qt mouse-release event. + """ if not self.pending_port: super().mouseReleaseEvent(event) return @@ -234,8 +336,8 @@ def mouseReleaseEvent(self, event): self.project_controller.add_connection(self.pending_port.instance, port.instance) self._cancel_temp_connection() - # -------------------------------------------------------------- - def delete_selected(self): + def delete_selected(self) -> None: + """Remove all selected blocks and connections from the project.""" for item in self.diagram_scene.selectedItems(): if isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) @@ -243,9 +345,8 @@ def delete_selected(self): elif isinstance(item, ConnectionItem): self.project_controller.remove_connection(item.instance) - # -------------------------------------------------------------- - def clear_scene(self): - + def clear_scene(self) -> None: + """Remove all blocks and connections from the scene and reset state.""" for block in list(self.block_items.values()): self.project_controller.remove_block(block.instance) @@ -254,16 +355,12 @@ def clear_scene(self): self.pending_port = None - # -------------------------------------------------------------------------- - # Private methods - # -------------------------------------------------------------------------- - def _cancel_temp_connection(self): - self.diagram_scene.removeItem(self.temp_connection) - self.temp_connection = None - self.pending_port = None + def scale_view(self, factor: float) -> None: + """Scale the view by ``factor``, clamped to the allowed zoom range. - # -------------------------------------------------------------- - def scale_view(self, factor): + Args: + factor: Multiplicative zoom factor to apply. + """ current_scale = self.transform().m11() min_scale, max_scale = 0.2, 5.0 @@ -271,20 +368,31 @@ def scale_view(self, factor): if min_scale <= new_scale <= max_scale: self.scale(factor, factor) - # -------------------------------------------------------------- - def _on_color_scheme_changed(self, *_): + + # -------------------------------------------------------------------------- + # Private Methods + # -------------------------------------------------------------------------- + + def _cancel_temp_connection(self) -> None: + """Remove the temporary wire and reset the pending-port state.""" + self.diagram_scene.removeItem(self.temp_connection) + self.temp_connection = None + self.pending_port = None + + def _on_color_scheme_changed(self, *_) -> None: + """Schedule a theme refresh after the system colour scheme changes.""" QTimer.singleShot(0, self._apply_theme_from_system) - # -------------------------------------------------------------- - def _apply_theme_from_system(self): + def _apply_theme_from_system(self) -> None: + """Reload the theme and repaint all scene items to match the system palette.""" self.theme = make_theme() self.diagram_scene.setBackgroundBrush(self.theme.scene_bg) self._refresh_theme_items() self.viewport().update() self.diagram_scene.update() - # -------------------------------------------------------------- - def _refresh_theme_items(self): + def _refresh_theme_items(self) -> None: + """Update colours on all block and connection items to match the current theme.""" for block in self.block_items.values(): block.update() for port in block.port_items: @@ -296,8 +404,8 @@ def _refresh_theme_items(self): conn.update_position() conn.update() - # -------------------------------------------------------------- - def _center_on_diagram(self): + def _center_on_diagram(self) -> None: + """Fit the view to the bounding rect of all scene items with a small margin.""" scene = self.diagram_scene items_rect = scene.itemsBoundingRect() diff --git a/pySimBlocks/gui/widgets/toolbar_view.py b/pySimBlocks/gui/widgets/toolbar_view.py index af6e683..6fa9b90 100644 --- a/pySimBlocks/gui/widgets/toolbar_view.py +++ b/pySimBlocks/gui/widgets/toolbar_view.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from PySide6.QtWidgets import QToolBar, QMessageBox, QProgressDialog, QApplication from PySide6.QtGui import QAction from PySide6.QtCore import Qt @@ -33,12 +35,34 @@ from pySimBlocks.gui.addons.sofa.sofa_dialog import SofaDialog from pySimBlocks.gui.addons.sofa.sofa_service import SofaService -class ToolBarView(QToolBar): - def __init__(self, - saver: ProjectSaver, - runner: SimulationRunner, - project_controller: ProjectController): +class ToolBarView(QToolBar): + """Application toolbar providing save, run, plot, and add-on actions. + + Attributes: + saver: Service used to persist the project to disk. + runner: Service used to launch a simulation. + project_controller: Controller coordinating model and view mutations. + sofa_service: Service managing SOFA-specific operations. + sofa_action: Toolbar action for opening the SOFA dialog. + """ + + def __init__( + self, + saver: ProjectSaver, + runner: SimulationRunner, + project_controller: ProjectController, + ): + """Initialize the ToolBarView and register all toolbar actions. + + Args: + saver: Service used to save the project to YAML. + runner: Service used to launch a simulation. + project_controller: Controller coordinating model and view mutations. + + Raises: + None. + """ super().__init__() self.saver = saver @@ -75,24 +99,34 @@ def __init__(self, self.sofa_action.triggered.connect(self.on_open_sofa_dialog) self.addAction(self.sofa_action) - def on_save(self): + + # -------------------------------------------------------------------------- + # Public Methods + # -------------------------------------------------------------------------- + + def on_save(self) -> None: + """Save the project and clear the dirty flag.""" self.saver.save(self.project_controller.project_state, self.project_controller.view.block_items) self.project_controller.clear_dirty() - def on_export_project(self): + def on_export_project(self) -> None: + """Prompt to save if dirty, then export the project.""" window = self.parent() if window.confirm_discard_or_save("exporting"): self.saver.export(self.project_controller.project_state, self.project_controller.view.block_items) - def on_open_display_yaml(self): + def on_open_display_yaml(self) -> None: + """Open the YAML viewer dialog for the current project.""" dialog = DisplayYamlDialog(self.project_controller.project_state, self.project_controller.view) dialog.exec() - def on_open_simulation_settings(self): + def on_open_simulation_settings(self) -> None: + """Open the simulation settings dialog.""" dialog = SettingsDialog(self.project_controller.project_state, self.project_controller, self.parent()) dialog.exec() - def on_run_sim(self): + def on_run_sim(self) -> None: + """Run the simulation with a busy progress dialog.""" dlg = QProgressDialog(self) dlg.setWindowTitle("Simulation") dlg.setLabelText("Running simulation...\nPlease wait.") @@ -118,8 +152,8 @@ def on_run_sim(self): QMessageBox.Ok, ) - - def on_plot_logs(self): + def on_plot_logs(self) -> None: + """Open the plot dialog if simulation logs are available.""" flag, msg = self.project_controller.project_state.can_plot() if not flag: QMessageBox.warning( @@ -129,17 +163,20 @@ def on_plot_logs(self): QMessageBox.Ok, ) return - self._plot_dialog = PlotDialog(self.project_controller.project_state, self.parent()) # keep ref because of python garbage collector + self._plot_dialog = PlotDialog(self.project_controller.project_state, self.parent()) # keep ref because of python garbage collector self._plot_dialog.show() + def set_running(self, running: bool) -> None: + """Enable or disable all toolbar actions based on the running state. - def set_running(self, running: bool): + Args: + running: True to disable all actions, False to re-enable them. + """ for action in self.actions(): action.setEnabled(not running) - ##################################### - # Adds on - def refresh_sofa_button(self): + def refresh_sofa_button(self) -> None: + """Show or hide the SOFA toolbar button based on project contents.""" if self.project_controller.has_sofa_block(): if self.sofa_action not in self.actions(): self.addAction(self.sofa_action) @@ -147,7 +184,8 @@ def refresh_sofa_button(self): if self.sofa_action in self.actions(): self.removeAction(self.sofa_action) - def on_open_sofa_dialog(self): + def on_open_sofa_dialog(self) -> None: + """Open the SOFA dialog if SOFA prerequisites are satisfied.""" ok, msg, details = self.sofa_service.can_use_sofa() if not ok: QMessageBox.warning( diff --git a/pySimBlocks/project/build_model.py b/pySimBlocks/project/build_model.py index b2b8e1f..c28d0c9 100644 --- a/pySimBlocks/project/build_model.py +++ b/pySimBlocks/project/build_model.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import importlib from pathlib import Path from typing import Dict, Any @@ -25,30 +27,33 @@ from pySimBlocks.core.model import Model -# ============================================================ -# Public API -# ============================================================ - def build_model_from_dict( model: Model, model_data: Dict[str, Any], params_dir: Path | None = None, ) -> None: - """ - Build a Model instance from an already loaded model dictionary. - """ + """Build a Model instance from an already-loaded model dictionary. - # ------------------------------------------------------------ - # Load block registry - # ------------------------------------------------------------ + Reads the block registry index, instantiates each block described in + ``model_data``, and wires up the connections. + + Args: + model: The :class:`Model` instance to populate with blocks and + connections. + model_data: Model dictionary with ``'blocks'`` and ``'connections'`` + sections, as produced by :func:`load_project_config`. + params_dir: Directory used to resolve relative file paths in block + parameters (e.g. scene files, function files). Passed to each + block's ``adapt_params`` classmethod. + + Raises: + ValueError: If a block type or category is not found in the registry. + """ index_path = Path(__file__).parent / "pySimBlocks_blocks_index.yaml" with index_path.open("r") as f: blocks_index = yaml.safe_load(f) or {} - # ------------------------------------------------------------ - # Instantiate blocks - # ------------------------------------------------------------ for desc in model_data.get("blocks", []): name = desc["name"] category = desc["category"] @@ -65,27 +70,15 @@ def build_model_from_dict( f"Unknown block '{block_type}' in category '{category}'." ) - # -------------------------------------------------------- - # Load Python block class - # -------------------------------------------------------- module = importlib.import_module(block_info["module"]) BlockClass = getattr(module, block_info["class"]) - # -------------------------------------------------------- - # Load parameters - # -------------------------------------------------------- params = desc.get("parameters", {}) - # -------------------------------------------------------- - # Instantiate block - # -------------------------------------------------------- params = BlockClass.adapt_params(params, params_dir=params_dir) block = BlockClass(name=name, **params) model.add_block(block) - # ------------------------------------------------------------ - # Connections - # ------------------------------------------------------------ for src, dst in model_data.get("connections", []): src_block, src_port = src.split(".") dst_block, dst_port = dst.split(".") diff --git a/pySimBlocks/project/generate_run_script.py b/pySimBlocks/project/generate_run_script.py index 73430df..5d5e6b3 100644 --- a/pySimBlocks/project/generate_run_script.py +++ b/pySimBlocks/project/generate_run_script.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path @@ -38,42 +40,50 @@ plot_from_config(logs, plot_cfg) """ + def generate_python_content( project_yaml_path: str, enable_plots: bool = True, ) -> str: + """Render the run script template for a given project YAML path. + + Args: + project_yaml_path: Path string to the project YAML file, embedded + verbatim into the generated script. + enable_plots: Whether to include the plotting call in the script. + + Returns: + The rendered run script as a string. + """ return RUN_TEMPLATE.format( project_path=project_yaml_path, enable_plots=enable_plots, ) - def generate_run_script( *, project_dir: Path | None = None, project_yaml: Path | None = None, output: Path | None = None, ) -> None: + """Generate a canonical ``run.py`` script for a pySimBlocks project. + + Exactly one of ``project_dir`` or ``project_yaml`` must be provided. + + Args: + project_dir: Path to a project folder containing ``project.yaml``. + The output script defaults to ``/run.py``. + project_yaml: Explicit path to a ``project.yaml`` file. + output: Output path for the generated script. Defaults to + ``/run.py`` in ``project_dir`` mode or ``run.py`` + in the current directory in ``project_yaml`` mode. + + Raises: + ValueError: If both ``project_dir`` and ``project_yaml`` are given, + or if neither is given. + FileNotFoundError: If the resolved project YAML file does not exist. """ - Generate a canonical run.py script for a pySimBlocks project. - - Exactly one of the following modes must be used: - - project_dir (project.yaml expected) - - project_yaml - - Parameters - ---------- - project_dir : Path, optional - Path to a project folder containing project.yaml. - - project_yaml : Path, optional - Path to project.yaml (explicit unified mode). - - output : Path, optional - Output run.py path (default: /run.py). - """ - has_project_yaml_mode = project_yaml is not None if project_dir and has_project_yaml_mode: @@ -86,7 +96,6 @@ def generate_run_script( "You must specify one mode: project_dir or project_yaml." ) - # Unified project_dir mode if project_dir: project_dir = Path(project_dir) project_yaml = project_dir / "project.yaml" @@ -96,7 +105,6 @@ def generate_run_script( content = generate_python_content(project_yaml_path=project_yaml.name) - # Unified explicit project_yaml mode elif has_project_yaml_mode: project_yaml = Path(project_yaml) output = Path(output or "run.py") diff --git a/pySimBlocks/project/generate_sofa_controller.py b/pySimBlocks/project/generate_sofa_controller.py index 6b21434..4839e01 100644 --- a/pySimBlocks/project/generate_sofa_controller.py +++ b/pySimBlocks/project/generate_sofa_controller.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import importlib.util import inspect import os @@ -29,10 +31,8 @@ import yaml -def _load_scene_in_subprocess(scene_path, conn): - """ - Load SOFA scene in subprocess and return controller file path. - """ +def _load_scene_in_subprocess(scene_path, conn) -> None: + """Load a SOFA scene in a subprocess and send back the controller source file path.""" try: scene_path = Path(scene_path).resolve() scene_dir = scene_path.parent @@ -64,8 +64,18 @@ def _load_scene_in_subprocess(scene_path, conn): def detect_controller_file_from_scene(scene_file: Path) -> Path: - """ - Automatically get controller path from scene. + """Determine the controller source file by loading the SOFA scene in a subprocess. + + Args: + scene_file: Path to the SOFA Python scene file. The scene's + ``createScene`` function must return ``(root, controller)``. + + Returns: + Path to the Python source file that defines the controller class. + + Raises: + RuntimeError: If the controller file cannot be determined (e.g. the + scene does not return a controller). """ parent_conn, child_conn = Pipe() p = Process(target=_load_scene_in_subprocess, args=(scene_file, child_conn)) @@ -85,6 +95,14 @@ def detect_controller_file_from_scene(scene_file: Path) -> Path: def inject_base_dir(src: str) -> str: + """Inject a ``BASE_DIR`` declaration after the last import statement if not present. + + Args: + src: Source code string of the controller file. + + Returns: + Source code with ``BASE_DIR = Path(__file__).resolve().parent`` injected. + """ if "BASE_DIR = Path(__file__).resolve().parent" in src: return src @@ -106,8 +124,12 @@ def inject_project_path_into_controller( controller_file: Path, project_yaml: Path, ) -> None: - """ - Inject or replace project_yaml attribute inside SofaPysimBlocksController __init__. + """Inject or update the ``self.project_yaml`` assignment in the controller ``__init__``. + + Args: + controller_file: Path to the controller Python source file. + project_yaml: Path to the ``project.yaml`` file to reference from + the controller. """ src = controller_file.read_text() src = inject_base_dir(src) @@ -136,6 +158,7 @@ def inject_project_path_into_controller( def _load_project_yaml(project_yaml: Path) -> dict: + """Load and return a project YAML file as a dict.""" if not project_yaml.exists(): raise FileNotFoundError(f"project.yaml not found: {project_yaml}") @@ -146,6 +169,7 @@ def _load_project_yaml(project_yaml: Path) -> dict: def _find_sofa_block(raw_project: dict) -> dict: + """Return the first SofaPlant or SofaExchangeIO block dict from the project diagram.""" diagram = raw_project.get("diagram", {}) if not isinstance(diagram, dict): raise ValueError("'diagram' section must be a mapping") @@ -171,6 +195,7 @@ def _find_sofa_block(raw_project: dict) -> dict: def _resolve_scene_file(project_yaml: Path, sofa_block: dict) -> Path: + """Resolve the absolute scene file path from a SOFA block's parameters.""" params = sofa_block.get("parameters", {}) if not isinstance(params, dict): raise ValueError( @@ -193,6 +218,25 @@ def generate_sofa_controller( project_dir: Path | None = None, project_yaml: Path | None = None, ) -> None: + """Update the SOFA controller file with the project YAML path. + + Finds the SOFA scene file from the project, detects the controller class, + and injects or replaces the ``self.project_yaml`` assignment so the + controller can locate the project at runtime. + + Exactly one of ``project_dir`` or ``project_yaml`` must be provided. + + Args: + project_dir: Path to a project folder containing ``project.yaml``. + project_yaml: Explicit path to a ``project.yaml`` file. + + Raises: + ValueError: If both or neither of ``project_dir`` / ``project_yaml`` + are given. + FileNotFoundError: If ``project.yaml`` or the scene file is not found. + RuntimeError: If no SOFA block is found in the project or the + controller file cannot be detected. + """ has_project_path = project_yaml is not None if project_dir and has_project_path: diff --git a/pySimBlocks/project/load_project_config.py b/pySimBlocks/project/load_project_config.py index 429dbf1..a263283 100644 --- a/pySimBlocks/project/load_project_config.py +++ b/pySimBlocks/project/load_project_config.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path from typing import Any, Dict, Tuple @@ -32,6 +34,7 @@ def _validate_schema_version(raw: Dict[str, Any]) -> None: + """Raise if ``schema_version`` is missing or not equal to 1.""" schema_version = raw.get("schema_version", None) if schema_version != 1: raise ValueError( @@ -41,6 +44,7 @@ def _validate_schema_version(raw: Dict[str, Any]) -> None: def _load_scope(raw: Dict[str, Any], project_yaml: Path) -> Tuple[Any, Dict[str, Any]]: + """Load the external parameters module and return (module, scope dict).""" simulation = raw.get("simulation", {}) if not isinstance(simulation, dict): raise ValueError("'simulation' section must be a mapping") @@ -61,6 +65,7 @@ def _load_scope(raw: Dict[str, Any], project_yaml: Path) -> Tuple[Any, Dict[str, def _build_plot_config(sim_data: Dict[str, Any]) -> PlotConfig | None: + """Build and validate a :class:`PlotConfig` from the simulation section, or return None.""" plots_data = sim_data.get("plots", None) if plots_data is None: return None @@ -77,6 +82,7 @@ def _adapt_diagram_to_model_dict( diagram_data: Dict[str, Any], scope: Dict[str, Any], ) -> Dict[str, Any]: + """Convert a diagram section dict into the model dict format expected by build_model.""" blocks = diagram_data.get("blocks", []) if not isinstance(blocks, list): raise ValueError("'diagram.blocks' section must be a list") @@ -142,11 +148,30 @@ def _adapt_diagram_to_model_dict( def load_project_config( project_yaml: str | Path, ) -> Tuple[SimulationConfig, Dict[str, Any], PlotConfig | None, str, Path]: - """ - Load a full pySimBlocks unified project.yaml configuration. + """Load a full pySimBlocks unified project.yaml configuration. + + Parses and validates all sections of the project file, evaluates parameter + expressions against the optional external parameters module, and returns + all configuration objects needed to build and run the simulation. + + Args: + project_yaml: Path to the unified ``project.yaml`` file. Returns: - (SimulationConfig, model_dict, PlotConfig | None, project_name, params_dir) + A tuple ``(sim_cfg, model_dict, plot_cfg, project_name, params_dir)`` + where: + + - ``sim_cfg``: validated :class:`SimulationConfig`. + - ``model_dict``: model dict with ``'blocks'`` and ``'connections'`` + sections ready to pass to :func:`build_model_from_dict`. + - ``plot_cfg``: :class:`PlotConfig` or None if no plots are defined. + - ``project_name``: project name string from ``project.name``. + - ``params_dir``: resolved directory of the project file. + + Raises: + FileNotFoundError: If the project file does not exist. + ValueError: If the file is malformed, the schema version is wrong, or + required fields are missing. """ project_yaml = Path(project_yaml) raw = _load_yaml(project_yaml) diff --git a/pySimBlocks/project/load_simulation_config.py b/pySimBlocks/project/load_simulation_config.py index 6429559..d9fa1f0 100644 --- a/pySimBlocks/project/load_simulation_config.py +++ b/pySimBlocks/project/load_simulation_config.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import importlib.util from pathlib import Path from typing import Dict, Any, Tuple @@ -26,10 +28,9 @@ import re from pySimBlocks.core.config import SimulationConfig -# --------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------- + def _load_yaml(path: Path) -> Dict[str, Any]: + """Load and return a YAML file as a dict.""" if not path.exists(): raise FileNotFoundError(f"Project file not found: {path}") @@ -42,11 +43,8 @@ def _load_yaml(path: Path) -> Dict[str, Any]: return data - -############################################################ -# External variables -############################################################ def _load_external_module(path: Path): + """Load a Python file as a module and return (module, module.__dict__).""" if not path.exists(): raise FileNotFoundError(f"External parameters module not found: {path}") @@ -60,18 +58,22 @@ def _load_external_module(path: Path): _EXTERNAL_REF_PATTERN = re.compile(r"#([A-Za-z_][A-Za-z0-9_]*)") + + def extract_external_refs(expr: str) -> set[str]: - """ - Extract all external references (#var) from an expression string. + """Extract all external reference names (``#var`` syntax) from an expression string. + + Args: + expr: A YAML value string potentially containing ``#name`` references. + + Returns: + Set of referenced variable names with the ``#`` prefix stripped. """ return set(_EXTERNAL_REF_PATTERN.findall(expr)) def _resolve_external_refs(obj: Any, external_module) -> Any: - """ - Recursively validate #var references using the external module. - Does NOT replace anything. - """ + """Recursively validate that all ``#var`` references exist in the external module.""" if isinstance(obj, str): refs = extract_external_refs(obj) for name in refs: @@ -93,7 +95,9 @@ def _resolve_external_refs(obj: Any, external_module) -> Any: return obj -def _check_no_external_refs(obj): + +def _check_no_external_refs(obj) -> None: + """Raise if any ``#var`` references are found when no external module is defined.""" if isinstance(obj, str): refs = extract_external_refs(obj) if refs: @@ -111,22 +115,22 @@ def _check_no_external_refs(obj): _check_no_external_refs(v) +def eval_value(value: Any, scope: dict) -> Any: + """Evaluate a single YAML value as a Python expression. -############################################################ -# EVAL -############################################################ -def eval_value(value: Any, scope: dict): - """ - Try to evaluate ANY value as a Python expression. - - Rules: - - value is first converted to string - - '#' 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 - """ + The value is converted to string, ``#`` prefixes are stripped, bare list + literals are wrapped in ``np.array()``, and the result is evaluated using + ``eval`` with a restricted namespace containing only ``np`` and ``scope``. + If evaluation fails the original value is returned unchanged. + + Args: + value: Raw YAML value (string, number, list, etc.). + scope: Variable scope for expression evaluation (from the external + parameters module). + Returns: + Evaluated Python object, or ``value`` unchanged if evaluation fails. + """ try: expr = str(value) expr = expr.replace("#", "") @@ -137,7 +141,17 @@ def eval_value(value: Any, scope: dict): return value -def eval_recursive(obj: Any, scope: dict): +def eval_recursive(obj: Any, scope: dict) -> Any: + """Recursively evaluate all values in a nested dict/list using :func:`eval_value`. + + Args: + obj: A nested dict, list, or scalar YAML value. + scope: Variable scope for expression evaluation. + + Returns: + The same structure with all leaf values passed through + :func:`eval_value`. + """ if isinstance(obj, dict): return {k: eval_recursive(v, scope) for k, v in obj.items()} @@ -146,17 +160,22 @@ def eval_recursive(obj: Any, scope: dict): return eval_value(obj, scope) -# --------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------- + def load_simulation_config( project_yaml: str | Path, ) -> Tuple[SimulationConfig, Dict[str, Any], Path]: - """ - Load simulation and diagram configuration from unified project.yaml. + """Load simulation and diagram configuration from a unified project.yaml. + + Args: + project_yaml: Path to the unified ``project.yaml`` file. Returns: - (SimulationConfig, model_dict, params_dir) + A tuple ``(SimulationConfig, model_dict, params_dir)`` where + ``params_dir`` is the directory of the project file. + + Raises: + FileNotFoundError: If the project file does not exist. + ValueError: If the file is malformed or required fields are missing. """ from pySimBlocks.project.load_project_config import load_project_config diff --git a/pySimBlocks/project/load_simulator.py b/pySimBlocks/project/load_simulator.py index 72301d7..6c2652b 100644 --- a/pySimBlocks/project/load_simulator.py +++ b/pySimBlocks/project/load_simulator.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + from pathlib import Path from typing import Tuple @@ -31,8 +33,23 @@ def load_simulator_from_project( project_yaml: str | Path, ) -> Tuple[Simulator, PlotConfig | None]: - """ - Build and return a ready-to-run Simulator from a unified project.yaml. + """Build and return a ready-to-run Simulator from a unified project.yaml. + + Loads the project configuration, constructs the model, and initializes a + :class:`Simulator` ready to call :meth:`~Simulator.run`. + + Args: + project_yaml: Path to the unified ``project.yaml`` file. + + Returns: + A tuple ``(sim, plot_cfg)`` where ``sim`` is the initialized + :class:`Simulator` and ``plot_cfg`` is the :class:`PlotConfig` or + None if no plots are configured. + + Raises: + FileNotFoundError: If the project file does not exist. + ValueError: If the project file is malformed or required fields are + missing. """ sim_cfg, model_dict, plot_cfg, project_name, params_dir = load_project_config( project_yaml diff --git a/pySimBlocks/project/plot_from_config.py b/pySimBlocks/project/plot_from_config.py index 9ce049f..58b1034 100644 --- a/pySimBlocks/project/plot_from_config.py +++ b/pySimBlocks/project/plot_from_config.py @@ -18,6 +18,8 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np import matplotlib.pyplot as plt @@ -25,20 +27,11 @@ def _stack_logged_signal(logs: dict, sig: str) -> np.ndarray: - """ - Stack a logged signal over time, preserving its 2D shape. - - Returns: - data: np.ndarray of shape (T, m, n) - - Raises: - ValueError: if the signal is not a list of 2D arrays with consistent shape. - """ + """Stack a logged signal over time into a (T, m, n) array.""" samples = logs[sig] if not isinstance(samples, list) or len(samples) == 0: raise ValueError(f"Signal '{sig}' has no samples in logs.") - # Find first non-None sample to define shape first = None for s in samples: if s is not None: @@ -68,7 +61,7 @@ def _stack_logged_signal(logs: dict, sig: str) -> np.ndarray: ) stacked.append(a) - data = np.stack(stacked, axis=0) # (T, m, n) + data = np.stack(stacked, axis=0) return data @@ -76,29 +69,31 @@ def plot_from_config( logs: dict, plot_cfg: PlotConfig | None, show: bool = True, - block: bool = True -): - """ - Plot logged simulation signals according to a PlotConfig. - - Supports 2D signals: - - scalar (1,1) - - column vectors (n,1) - - matrices (m,n) with n>1 - - Plot semantics: - - each component is plotted as a separate curve - - labels: - scalar: sig - vector: sig[i] - matrix: sig[r,c] + block: bool = True, +) -> None: + """Plot logged simulation signals according to a :class:`PlotConfig`. + + Each signal component is plotted as a separate step curve. Labels follow + the convention: scalar signals use ``sig``; column vector elements use + ``sig[i]``; matrix elements use ``sig[r,c]``. + + Args: + logs: Dictionary of logged signals as returned by + :meth:`~Simulator.run`. Must contain a ``'time'`` key. + plot_cfg: Plot configuration. If None, the function returns immediately + without producing any figures. + show: If True, call ``plt.show()`` after creating all figures. + block: Passed to ``plt.show()``; controls whether the call blocks. + + Raises: + KeyError: If any signal requested by ``plot_cfg`` was not logged, or + if ``'time'`` is missing from ``logs``. + ValueError: If any logged signal is not a list of 2D arrays with a + consistent shape. """ if plot_cfg is None: return - # ------------------------------------------------------------ - # Global validation: all plotted signals must be logged - # ------------------------------------------------------------ requested_signals = set() for plot in plot_cfg.plots: requested_signals.update(plot["signals"]) @@ -118,19 +113,11 @@ def plot_from_config( if "time" not in logs: raise KeyError("Logs must contain a 'time' entry.") - # ------------------------------------------------------------ - # Time base - # Your simulator logs time as np.array([t_step]) each step, - # so flatten() is appropriate. - # ------------------------------------------------------------ time = np.asarray(logs["time"]).flatten() T = len(time) if T == 0: return - # ------------------------------------------------------------ - # Plotting - # ------------------------------------------------------------ for plot in plot_cfg.plots: title = plot.get("title", "") signals = plot["signals"] @@ -138,7 +125,7 @@ def plot_from_config( plt.figure() for sig in signals: - data = _stack_logged_signal(logs, sig) # (T, m, n) + data = _stack_logged_signal(logs, sig) if data.shape[0] != T: raise ValueError( @@ -147,18 +134,15 @@ def plot_from_config( m, n = data.shape[1], data.shape[2] - # scalar if (m, n) == (1, 1): plt.step(time, data[:, 0, 0], where="post", label=sig) continue - # vector column (n,1) -> label sig[i] if n == 1: for i in range(m): plt.step(time, data[:, i, 0], where="post", label=f"{sig}[{i}]") continue - # matrix (m,n) -> label sig[r,c] for r in range(m): for c in range(n): plt.step(time, data[:, r, c], where="post", label=f"{sig}[{r},{c}]") diff --git a/pySimBlocks/real_time/real_time_runner.py b/pySimBlocks/real_time/real_time_runner.py index 23874f4..47f02c8 100644 --- a/pySimBlocks/real_time/real_time_runner.py +++ b/pySimBlocks/real_time/real_time_runner.py @@ -25,18 +25,16 @@ class RealTimeRunner: - """ - Generic real-time runner for pySimBlocks. + """Run a simulator step loop against a real-time clock. - Responsibilities: - - Provide an external clock (dt measured or provided). - - Push external inputs into ExternalInput blocks. - - Call Simulator.step(dt_override=...). - - Pull outputs from ExternalOutput blocks. + The runner measures or accepts an external timestep, forwards input values + to model blocks, advances the simulator, and collects output values. - Notes: - - No threading, no I/O drivers here. The application owns that. - - Designed to be embedded in user-specific real-time apps. + Attributes: + sim: Simulator instance driven by the runner. + input_blocks: Model blocks updated from external inputs at each tick. + output_blocks: Model blocks read to produce external outputs. + target_dt: Optional target period used for pacing. """ def __init__( @@ -48,22 +46,18 @@ def __init__( target_dt: Optional[float] = None, time_source: str = "perf_counter", # "perf_counter" | "time" ): - """ - Parameters - ---------- - sim: - Initialized Simulator instance (model already compiled). - input_blocks: - Mapping external input name -> block name in model - Example: {"camera": "Camera", "ref": "Reference"} - output_blocks: - Mapping external output name -> block name in model - Example: {"motor": "Motor"} - target_dt: - If provided, runner will pace the loop to approximately this period - (best effort). If None, no pacing. - time_source: - Timer function selection. + """Initialize the real-time runner. + + Args: + sim: Initialized simulator instance with a compiled model. + input_blocks: Names of model blocks that receive external inputs. + output_blocks: Names of model blocks that expose external outputs. + target_dt: Target loop period in seconds for optional pacing. + time_source: Clock source name, either ``"perf_counter"`` or + ``"time"``. + + Raises: + ValueError: If ``time_source`` is not supported. """ self.sim = sim self.input_blocks = {block_name: sim.model.get_block_by_name(block_name) for block_name in input_blocks} @@ -79,7 +73,15 @@ def __init__( self._t_prev: Optional[float] = None + + # --- Public methods --- + def initialize(self, t0: float = 0.0) -> None: + """Initialize the simulator and synchronize the runner clock. + + Args: + t0: Initial simulation time in seconds. + """ self.sim.initialize(t0) self._t_prev = self._now() @@ -90,23 +92,21 @@ def tick( dt: Optional[float] = None, pace: bool = False, ) -> Dict[str, np.ndarray]: - """ - Execute one real-time tick. - - Parameters - ---------- - inputs: - External inputs keyed by input_blocks mapping key. - Example: {"camera": markers_pos, "ref": ref_value} - dt: - Optional explicit dt_override. If None, dt is measured from wall clock. - pace: - If True and target_dt is set, sleep to approximate target period. - - Returns - ------- - outputs: - Dict mapping output key -> np.ndarray (n,1) + """Execute one real-time simulation tick. + + Args: + inputs: External input values keyed by block name. + dt: Explicit timestep override in seconds. If omitted, the runner + measures elapsed wall-clock time. + pace: If True, sleep after the step to approximate ``target_dt``. + + Returns: + Output values keyed by block name as column vectors. + + Raises: + KeyError: If a required input block value is missing. + RuntimeError: If an output block does not provide an ``"out"`` + value. """ if self._t_prev is None: self._t_prev = self._now() diff --git a/pySimBlocks/tools/blocks_registry.py b/pySimBlocks/tools/blocks_registry.py index c8473cb..f1b6550 100644 --- a/pySimBlocks/tools/blocks_registry.py +++ b/pySimBlocks/tools/blocks_registry.py @@ -27,19 +27,39 @@ from pySimBlocks.gui.blocks.block_meta import BlockMeta +# Mapping: category -> block_type -> BlockMeta instance. BlockRegistry = Dict[str, Dict[str, BlockMeta]] + def load_block_registry( metadata_root: Path | str | None = None, ) -> BlockRegistry: - + """Load all BlockMeta subclasses from the GUI blocks directory. + + Recursively scans metadata_root for Python files and registers every + BlockMeta subclass found. Defaults to ``pySimBlocks/gui/blocks/``. + + Args: + metadata_root: Root directory to scan. Defaults to the package's + ``gui/blocks/`` directory. + + Returns: + Registry mapping category names to dicts of block_type -> BlockMeta. + + Raises: + FileNotFoundError: If metadata_root does not exist. + ValueError: If two files define a BlockMeta with the same type + within the same category. + """ if metadata_root is None: metadata_root = Path(__file__).parents[1] / "gui" / "blocks" else: metadata_root = Path(metadata_root).resolve() if not metadata_root.exists(): - raise FileNotFoundError(f"blocks_metadata directory not found: {metadata_root}") + raise FileNotFoundError( + f"blocks_metadata directory not found: {metadata_root}" + ) registry: BlockRegistry = {} @@ -48,28 +68,25 @@ def load_block_registry( return registry + +# -------------------------------------------------------------------------- +# Private helpers +# -------------------------------------------------------------------------- + def _register_block_from_py( py_path: Path, registry: BlockRegistry, ) -> None: - """ - Import a *.py file and register all BlockMeta subclasses inside. - """ - + """Import a .py file and register all BlockMeta subclasses it contains.""" module_name = _path_to_module(py_path) - module = importlib.import_module(module_name) - doc_path = _resolve_doc_path(py_path) for _, obj in inspect.getmembers(module, inspect.isclass): - if not issubclass(obj, BlockMeta): - continue - if obj is BlockMeta: + if not issubclass(obj, BlockMeta) or obj is BlockMeta: continue meta: BlockMeta = obj() - meta.doc_path = doc_path category = meta.category @@ -85,17 +102,24 @@ def _register_block_from_py( registry[category][block_type] = meta -def _path_to_module(py_path: Path) -> str: - """ - Convert a file path to a Python module path. +def _path_to_module(py_path: Path) -> str: + """Convert a file path to a dotted Python module name. + Example: - pySimBlocks/blocks_metadata/operators/sum_meta.py - -> pySimBlocks.blocks_metadata.operators.sum_meta - """ - + ``pySimBlocks/gui/blocks/operators/sum.py`` + -> ``pySimBlocks.gui.blocks.operators.sum`` + + Args: + py_path: Absolute path to a Python source file. + + Returns: + Dotted module name relative to the package root. + + Raises: + RuntimeError: If py_path is not inside the package root. + """ py_path = py_path.with_suffix("") - package_root = Path(__file__).parents[1] # pySimBlocks/ try: @@ -105,23 +129,22 @@ def _path_to_module(py_path: Path) -> str: f"File {py_path} is not inside package root {package_root}" ) - module_name = ( - package_root.name - + "." - + rel_path.as_posix().replace("/", ".") - ) + return package_root.name + "." + rel_path.as_posix().replace("/", ".") - return module_name def _resolve_doc_path(py_path: Path) -> Optional[Path]: - """ - Resolve the documentation markdown file corresponding to a YAML metadata file. - + """Resolve the Markdown documentation file for a GUI block module. + Example: - blocks_metadata/systems/sofa/sofa_plant.yaml - -> docs/blocks_metadata/systems/sofa/sofa_plant.md + ``gui/blocks/systems/sofa/sofa_plant.py`` + -> ``docs/blocks/systems/sofa/sofa_plant.md`` + + Args: + py_path: Path to the GUI block Python file. + + Returns: + Path to the corresponding .md file if it exists, else None. """ - try: parts = list(py_path.parts) idx = parts.index("gui") @@ -129,9 +152,7 @@ def _resolve_doc_path(py_path: Path) -> Optional[Path]: return None doc_root = Path(*parts[:idx]) / "docs" - rel = Path(*parts[idx + 1 :]).with_suffix(".md") - doc_path = doc_root / rel return doc_path if doc_path.exists() else None diff --git a/pySimBlocks/tools/generate_blocks_index.py b/pySimBlocks/tools/generate_blocks_index.py index 393b9de..6f58011 100644 --- a/pySimBlocks/tools/generate_blocks_index.py +++ b/pySimBlocks/tools/generate_blocks_index.py @@ -18,20 +18,40 @@ # Authors: see Authors.txt # ****************************************************************************** +import ast import os import yaml -import ast from pathlib import Path + def iter_python_files(base_path): + """Yield paths to all non-__init__ Python files under base_path. + + Args: + base_path: Root directory to walk. + + Yields: + Absolute path strings to .py files. + """ for root, _, files in os.walk(base_path): for f in files: if f.endswith(".py") and f != "__init__.py": yield os.path.join(root, f) -def find_block_classes(filepath): - """Find all classes inheriting directly or indirectly from 'Block'.""" +def find_block_classes(filepath: str | Path) -> list[str]: + """Return names of all classes that inherit (directly or indirectly) from Block. + + Uses a name-based heuristic: a class is considered a Block subclass if + ``"block"`` appears (case-insensitive) in its own name or in any ancestor + name reachable within the same file. + + Args: + filepath: Path to the Python source file to analyse. + + Returns: + List of class names identified as Block subclasses. + """ with open(filepath, "r", encoding="utf-8") as f: source = f.read() @@ -49,37 +69,40 @@ def find_block_classes(filepath): parents.append(base.attr) class_parents[node.name] = parents - def is_block_class(cls): - visited = set() + def is_block_class(cls: str) -> bool: + visited: set[str] = set() to_visit = [cls] - while to_visit: current = to_visit.pop() if current in visited: continue visited.add(current) - if "block" in current.lower(): return True - - parents = class_parents.get(current, []) - to_visit.extend(parents) - + to_visit.extend(class_parents.get(current, [])) return False - block_classes = [] - for cls in class_parents: - if is_block_class(cls): - block_classes.append(cls) + return [cls for cls in class_parents if is_block_class(cls)] - return block_classes - -def generate_blocks_index(): +def generate_blocks_index() -> dict: + """Scan the blocks directory and write the YAML block index. + + For each block group (subdirectory of ``pySimBlocks/blocks/``), scans + Python files, identifies Block subclasses, and records their class name + and dotted module path. The result is written to + ``pySimBlocks/project/pySimBlocks_blocks_index.yaml``. + + Returns: + The generated index dict (mirrors the written YAML content). + """ blocks_dir = Path(__file__).resolve().parents[1] / "blocks" - output_path = Path(__file__).resolve().parents[1] / "project" / "pySimBlocks_blocks_index.yaml" + output_path = ( + Path(__file__).resolve().parents[1] + / "project" / "pySimBlocks_blocks_index.yaml" + ) - index = {} + index: dict = {} for group in os.listdir(blocks_dir): group_path = blocks_dir / group @@ -99,9 +122,8 @@ def generate_blocks_index(): if not classes: continue - file_stem = Path(filepath).stem # snake_case name -> key in YAML + file_stem = Path(filepath).stem - # Compute module path rel_path = filepath.split("pySimBlocks")[-1].lstrip("/\\") module_path = "pySimBlocks." + rel_path.replace("/", ".").replace("\\", ".").removesuffix(".py") @@ -122,3 +144,4 @@ def generate_blocks_index(): if __name__ == "__main__": generate_blocks_index() + diff --git a/pyproject.toml b/pyproject.toml index 746d875..b58d7e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,12 @@ examples = [ "qpsolvers[osqp,clarabel]", ] +docs = [ + "sphinx", + "myst-parser", + "furo", +] + tests = [ "pytest", "pytest-qt",