From a7125b98b760f9e9cd99bcb0a4c91f799016d5ef Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 08:10:03 +0100 Subject: [PATCH 01/33] feat(docs): adding Contributing.md --- CONTRIBUTING.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..03e56ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,112 @@ +# 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"` | + +--- + +## Running tests + +```bash +pytest tests/ +``` + +--- + +© 2026 Université de Lille & INRIA +Licensed under LGPL-3.0-or-later From 6953f78b4a05a7343d497d8ad33542262a3adf92 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 11:01:48 +0100 Subject: [PATCH 02/33] feat(docs): format core docstring --- CONTRIBUTING.md | 3 +- pySimBlocks/core/block.py | 147 ++++++++++++---------- pySimBlocks/core/block_source.py | 70 +++++++---- pySimBlocks/core/config.py | 46 ++++--- pySimBlocks/core/fixed_time_manager.py | 47 +++++-- pySimBlocks/core/model.py | 147 +++++++++++++++------- pySimBlocks/core/scheduler.py | 28 ++++- pySimBlocks/core/simulator.py | 165 +++++++++++++++---------- pySimBlocks/core/task.py | 90 +++++++++----- 9 files changed, 499 insertions(+), 244 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03e56ec..e0bbbe0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,8 @@ Contributions are welcome in the following areas: ## Docstring format -pySimBlocks uses **Google-style docstrings**. Every public class and method must have one. +pySimBlocks uses **Google-style docstrings**. Every public class and method must +have one. ### Class 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..5ee6652 100644 --- a/pySimBlocks/core/block_source.py +++ b/pySimBlocks/core/block_source.py @@ -24,41 +24,58 @@ 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. + + Attributes: + direct_feedthrough: Always False for sources. + is_source: Always True for sources. """ 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 +91,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..bfb0ac0 100644 --- a/pySimBlocks/core/config.py +++ b/pySimBlocks/core/config.py @@ -27,11 +27,18 @@ # --------------------------------------------------------------------- @dataclass(frozen=True) class SimulationConfig: - """ - Simulation execution configuration. - - This object contains ONLY execution-related parameters. - It must not contain any model or block-specific information. + """Simulation execution configuration. + + Contains only execution-related parameters. Must not hold any + model or block-specific information. + + Attributes: + dt: Simulation time step in seconds. + T: Simulation end time in seconds. + t0: Simulation start time in seconds. + solver: Integration scheme, either ``"fixed"`` or ``"variable"``. + logging: List of signal names to log during simulation. + clock: Clock source, either ``"internal"`` or ``"external"``. """ dt: float @@ -39,11 +46,15 @@ class SimulationConfig: t0: float = 0.0 solver: str = "fixed" logging: List[str] = field(default_factory=list) - clock: str = "internal" # "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 +77,25 @@ 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. + + Attributes: + plots: List of plot descriptors. Each descriptor is a dict that + must contain at least a ``"signals"`` key with a list of + signal names. """ 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) From 1d0b98ee55dca99e822f751371f5c405fb4be57e Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 11:38:13 +0100 Subject: [PATCH 03/33] feat(docs): format tools docstring --- pySimBlocks/tools/blocks_registry.py | 89 +++++++++++++--------- pySimBlocks/tools/generate_blocks_index.py | 67 ++++++++++------ 2 files changed, 100 insertions(+), 56 deletions(-) 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() + From ab8b351bdfd52f0e844158aaecbbc51e1d663f1c Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 12:46:39 +0100 Subject: [PATCH 04/33] feat(docs): format core blocks sources docstring --- pySimBlocks/blocks/sources/chirp.py | 97 ++++++++++---- pySimBlocks/blocks/sources/constant.py | 61 +++++---- pySimBlocks/blocks/sources/file_source.py | 103 ++++++++++----- pySimBlocks/blocks/sources/function_source.py | 83 +++++++++--- pySimBlocks/blocks/sources/ramp.py | 79 ++++++------ pySimBlocks/blocks/sources/sinusoidal.py | 80 ++++++------ pySimBlocks/blocks/sources/step.py | 118 +++++++----------- pySimBlocks/blocks/sources/white_noise.py | 79 ++++++------ 8 files changed, 429 insertions(+), 271 deletions(-) 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..0b0ab02 100644 --- a/pySimBlocks/blocks/sources/function_source.py +++ b/pySimBlocks/blocks/sources/function_source.py @@ -29,18 +29,18 @@ 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: + y = f(t, dt) - Summary: - Computes: - y = f(t, dt) + 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. - 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. + Attributes: + output_keys: List of output port names produced by the function. """ def __init__( @@ -50,6 +50,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 +79,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 +153,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 +223,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..b49f872 100644 --- a/pySimBlocks/blocks/sources/ramp.py +++ b/pySimBlocks/blocks/sources/ramp.py @@ -18,44 +18,27 @@ # 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 +49,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 +74,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..fe72de9 100644 --- a/pySimBlocks/blocks/sources/sinusoidal.py +++ b/pySimBlocks/blocks/sources/sinusoidal.py @@ -18,46 +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 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 +51,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 +91,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..c6335ed 100644 --- a/pySimBlocks/blocks/sources/white_noise.py +++ b/pySimBlocks/blocks/sources/white_noise.py @@ -18,45 +18,27 @@ # 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 +49,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 +77,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 +88,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) From d96b67381cd46cda1ae0bc6e9eaa08e0c469e017 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 12:46:52 +0100 Subject: [PATCH 05/33] feat(docs): format core blocks controllers docstring --- pySimBlocks/blocks/controllers/pid.py | 130 ++++++++++-------- .../blocks/controllers/state_feedback.py | 97 ++++++++----- 2 files changed, 133 insertions(+), 94 deletions(-) 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..9e084de 100644 --- a/pySimBlocks/blocks/controllers/state_feedback.py +++ b/pySimBlocks/blocks/controllers/state_feedback.py @@ -23,40 +23,34 @@ 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): + """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 +70,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 +104,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.") From dac5ec36aa63e1654d1c1c6fa077ef72bc811417 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 12:47:19 +0100 Subject: [PATCH 06/33] fix(docs): add arrayLike warning for type hint --- CONTRIBUTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0bbbe0..c4f4c58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,6 +99,14 @@ Follow standard Python conventions: | 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 From 7fdbeb2cf4234128f1831dbb12f4cde0d5efbfc8 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 13:43:23 +0100 Subject: [PATCH 07/33] fix(docs): define equations docstring format --- .../blocks/controllers/state_feedback.py | 7 +- .../blocks/interfaces/external_input.py | 58 ++++--- .../blocks/interfaces/external_output.py | 66 ++++---- pySimBlocks/blocks/observers/luenberger.py | 121 +++++++++----- .../blocks/optimizers/quadratic_program.py | 158 ++++++++---------- pySimBlocks/blocks/sources/function_source.py | 1 + pySimBlocks/blocks/sources/ramp.py | 1 + pySimBlocks/blocks/sources/sinusoidal.py | 1 + pySimBlocks/blocks/sources/white_noise.py | 4 +- 9 files changed, 223 insertions(+), 194 deletions(-) diff --git a/pySimBlocks/blocks/controllers/state_feedback.py b/pySimBlocks/blocks/controllers/state_feedback.py index 9e084de..108b5de 100644 --- a/pySimBlocks/blocks/controllers/state_feedback.py +++ b/pySimBlocks/blocks/controllers/state_feedback.py @@ -18,7 +18,11 @@ # Authors: see Authors.txt # ****************************************************************************** +from __future__ import annotations + import numpy as np +from numpy.typing import ArrayLike + from pySimBlocks.core.block import Block @@ -26,6 +30,7 @@ class StateFeedback(Block): """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. @@ -37,7 +42,7 @@ class StateFeedback(Block): 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: 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/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/function_source.py b/pySimBlocks/blocks/sources/function_source.py index 0b0ab02..d81977b 100644 --- a/pySimBlocks/blocks/sources/function_source.py +++ b/pySimBlocks/blocks/sources/function_source.py @@ -32,6 +32,7 @@ class FunctionSource(BlockSource): """User-defined source block driven by a callable. Computes outputs at each step by calling a user-provided function: + y = f(t, dt) The function must accept exactly ``(t, dt)`` as positional arguments diff --git a/pySimBlocks/blocks/sources/ramp.py b/pySimBlocks/blocks/sources/ramp.py index b49f872..c7792c2 100644 --- a/pySimBlocks/blocks/sources/ramp.py +++ b/pySimBlocks/blocks/sources/ramp.py @@ -29,6 +29,7 @@ class Ramp(BlockSource): """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 diff --git a/pySimBlocks/blocks/sources/sinusoidal.py b/pySimBlocks/blocks/sources/sinusoidal.py index fe72de9..a60ff68 100644 --- a/pySimBlocks/blocks/sources/sinusoidal.py +++ b/pySimBlocks/blocks/sources/sinusoidal.py @@ -29,6 +29,7 @@ class Sinusoidal(BlockSource): """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 diff --git a/pySimBlocks/blocks/sources/white_noise.py b/pySimBlocks/blocks/sources/white_noise.py index c6335ed..68d00e5 100644 --- a/pySimBlocks/blocks/sources/white_noise.py +++ b/pySimBlocks/blocks/sources/white_noise.py @@ -29,7 +29,9 @@ class WhiteNoise(BlockSource): """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)``. + 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 From ae838f86d9ba5ddeb095a3258458baabf00cdbdd Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:01:55 +0100 Subject: [PATCH 08/33] feat(docs): format core blocks systems docstring --- .../blocks/systems/linear_state_space.py | 94 +++--- .../blocks/systems/non_linear_state_space.py | 159 +++++----- .../blocks/systems/polytopic_state_space.py | 98 ++++-- .../blocks/systems/sofa/sofa_controller.py | 297 +++++++----------- .../blocks/systems/sofa/sofa_exchange_i_o.py | 126 ++++---- pySimBlocks/blocks/systems/sofa/sofa_plant.py | 172 +++++----- 6 files changed, 497 insertions(+), 449 deletions(-) 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) From fa73785877a785ff76cea423463d615e826c9d8e Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:21:57 +0100 Subject: [PATCH 09/33] feat(docs): format core blocks operators docstring --- .../blocks/operators/algebraic_function.py | 148 +++++++++++------ pySimBlocks/blocks/operators/dead_zone.py | 116 +++++++++----- pySimBlocks/blocks/operators/delay.py | 125 ++++++++------- pySimBlocks/blocks/operators/demux.py | 76 +++++---- .../blocks/operators/discrete_derivator.py | 124 ++++++-------- .../blocks/operators/discrete_integrator.py | 151 ++++++++---------- pySimBlocks/blocks/operators/gain.py | 132 ++++++++------- pySimBlocks/blocks/operators/mux.py | 83 ++++++---- pySimBlocks/blocks/operators/product.py | 113 +++++++------ pySimBlocks/blocks/operators/rate_limiter.py | 123 ++++++++------ pySimBlocks/blocks/operators/saturation.py | 113 +++++++------ pySimBlocks/blocks/operators/sum.py | 107 ++++++------- .../blocks/operators/zero_order_hold.py | 77 +++++---- 13 files changed, 805 insertions(+), 683 deletions(-) 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}." From 2701d7af1883f638b35c657868eb4adadbb2c350 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:28:25 +0100 Subject: [PATCH 10/33] feat(docs): format project docstring --- pySimBlocks/project/build_model.py | 43 ++++----- pySimBlocks/project/generate_run_script.py | 50 ++++++----- .../project/generate_sofa_controller.py | 60 +++++++++++-- pySimBlocks/project/load_project_config.py | 31 ++++++- pySimBlocks/project/load_simulation_config.py | 89 +++++++++++-------- pySimBlocks/project/load_simulator.py | 21 ++++- pySimBlocks/project/plot_from_config.py | 68 ++++++-------- 7 files changed, 226 insertions(+), 136 deletions(-) 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}]") From 2573afbf0face544af7323d18b6ec6a58608fffc Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:28:38 +0100 Subject: [PATCH 11/33] feat(docs): format real time docstring --- pySimBlocks/real_time/real_time_runner.py | 86 +++++++++++------------ 1 file changed, 43 insertions(+), 43 deletions(-) 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() From 426619f95a38228f451bbced70fbc7cdb8318753 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:36:52 +0100 Subject: [PATCH 12/33] feat(docs): format gui models docstring --- pySimBlocks/gui/models/block_instance.py | 66 ++++++++++--- pySimBlocks/gui/models/connection_instance.py | 37 ++++++++ pySimBlocks/gui/models/port_instance.py | 41 +++++++- .../gui/models/project_simulation_params.py | 28 ++++++ pySimBlocks/gui/models/project_state.py | 93 +++++++++++++++++-- 5 files changed, 237 insertions(+), 28 deletions(-) diff --git a/pySimBlocks/gui/models/block_instance.py b/pySimBlocks/gui/models/block_instance.py index 95ed7d4..b4a5441 100644 --- a/pySimBlocks/gui/models/block_instance.py +++ b/pySimBlocks/gui/models/block_instance.py @@ -34,16 +34,29 @@ 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 +64,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 +126,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." From 86caee95dd8a0d6b7262fc7bb7a4acaaaca9e253 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:37:01 +0100 Subject: [PATCH 13/33] feat(docs): format gui main docstring --- pySimBlocks/gui/editor.py | 21 +- pySimBlocks/gui/main_window.py | 130 +++++++++--- pySimBlocks/gui/project_controller.py | 284 +++++++++++++++++++------- 3 files changed, 329 insertions(+), 106 deletions(-) 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/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/project_controller.py b/pySimBlocks/gui/project_controller.py index 5988ebd..acd6afd 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,38 @@ 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: + dirty_changed: Signal emitted with the new dirty flag value whenever + the unsaved-changes state changes. + 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. + """ 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 +79,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 +149,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 +168,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 +189,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 +219,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 +237,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 +277,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 +325,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 +354,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 +371,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) From a4ac793c32b2dcc5049273b71275fbb66bf73acf Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:51:30 +0100 Subject: [PATCH 14/33] feat(docs): format gui services and widgets docstring --- pySimBlocks/gui/models/block_instance.py | 4 +- pySimBlocks/gui/services/project_loader.py | 48 +++- pySimBlocks/gui/services/project_saver.py | 41 ++- pySimBlocks/gui/services/simulation_runner.py | 15 ++ pySimBlocks/gui/services/yaml_tools.py | 61 +++++ pySimBlocks/gui/widgets/block_list.py | 46 ++++ pySimBlocks/gui/widgets/diagram_view.py | 238 +++++++++++++----- pySimBlocks/gui/widgets/toolbar_view.py | 74 ++++-- 8 files changed, 437 insertions(+), 90 deletions(-) diff --git a/pySimBlocks/gui/models/block_instance.py b/pySimBlocks/gui/models/block_instance.py index b4a5441..89f4968 100644 --- a/pySimBlocks/gui/models/block_instance.py +++ b/pySimBlocks/gui/models/block_instance.py @@ -45,7 +45,9 @@ class BlockInstance: """ - # --- Class methods --- + # -------------------------------------------------------------------------- + # Class Methods + # -------------------------------------------------------------------------- @classmethod def copy(cls, block: Self) -> Self: 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( From ee1ed1ac92548b771c7ac7f38e1f525a13758c11 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 14:59:34 +0100 Subject: [PATCH 15/33] feat(docs): format gui graphics docstring --- pySimBlocks/gui/graphics/block_item.py | 98 ++++++-- pySimBlocks/gui/graphics/connection_item.py | 233 ++++++++++++-------- pySimBlocks/gui/graphics/port_item.py | 84 +++++-- pySimBlocks/gui/graphics/theme.py | 20 ++ 4 files changed, 314 insertions(+), 121 deletions(-) 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..ee1f248 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,6 +59,20 @@ 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. + + Attributes: + scene_bg: Scene background color. + block_bg: Default block background color. + block_bg_selected: Selected block background color. + block_border: Default block border color. + block_border_selected: Selected block border color. + text: Default text color. + text_selected: Selected text color. + wire: Connection wire color. + port_in: Input port color. + port_out: Output port color. + """ scene_bg: QColor block_bg: QColor @@ -75,6 +90,11 @@ class Theme: 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) From 08ccb25046f38e38303feea37be5de81ab77da1c Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 15:06:05 +0100 Subject: [PATCH 16/33] feat(docs): format gui dialog docstring --- pySimBlocks/gui/dialogs/block_dialog.py | 40 +++++++++++--- .../gui/dialogs/display_yaml_dialog.py | 24 +++++++-- pySimBlocks/gui/dialogs/help_dialog.py | 11 ++++ pySimBlocks/gui/dialogs/plot_dialog.py | 47 ++++++++++------ pySimBlocks/gui/dialogs/settings/plots.py | 53 ++++++++++++++----- pySimBlocks/gui/dialogs/settings/project.py | 29 ++++++++++ .../gui/dialogs/settings/simulation.py | 32 +++++++++-- pySimBlocks/gui/dialogs/settings_dialog.py | 32 ++++++++++- pySimBlocks/gui/dialogs/unsaved_dialog.py | 23 +++++++- 9 files changed, 246 insertions(+), 45 deletions(-) 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() From 3255e4c38e43e38ff7c2e594652357e482b04695 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 15:09:57 +0100 Subject: [PATCH 17/33] feat(docs): format gui addson docstring --- pySimBlocks/gui/addons/sofa/sofa_dialog.py | 60 ++++++++++++++++++--- pySimBlocks/gui/addons/sofa/sofa_service.py | 57 +++++++++++++++++--- 2 files changed, 103 insertions(+), 14 deletions(-) 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") From 30b7f5f6f8d80a92406bf2a4a8ff6841b2f70413 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 15:26:38 +0100 Subject: [PATCH 18/33] feat(docs): format gui blocks docstring --- .../gui/blocks/block_dialog_session.py | 22 ++ pySimBlocks/gui/blocks/block_meta.py | 188 ++++++++++-------- pySimBlocks/gui/blocks/controllers/pid.py | 22 ++ .../gui/blocks/controllers/state_feedback.py | 10 + .../gui/blocks/interfaces/external_input.py | 9 + .../gui/blocks/interfaces/external_output.py | 9 + .../gui/blocks/observers/luenberger.py | 9 + .../blocks/operators/algebraic_function.py | 54 ++++- pySimBlocks/gui/blocks/operators/dead_zone.py | 9 + pySimBlocks/gui/blocks/operators/delay.py | 9 + pySimBlocks/gui/blocks/operators/demux.py | 23 +++ .../blocks/operators/discrete_derivator.py | 9 + .../blocks/operators/discrete_integrator.py | 9 + pySimBlocks/gui/blocks/operators/gain.py | 9 + pySimBlocks/gui/blocks/operators/mux.py | 23 +++ pySimBlocks/gui/blocks/operators/product.py | 23 +++ .../gui/blocks/operators/rate_limiter.py | 9 + .../gui/blocks/operators/saturation.py | 9 + pySimBlocks/gui/blocks/operators/sum.py | 27 ++- .../gui/blocks/operators/zero_order_hold.py | 9 + .../blocks/optimizers/quadratic_program.py | 9 + pySimBlocks/gui/blocks/parameter_meta.py | 11 + pySimBlocks/gui/blocks/port_meta.py | 8 + pySimBlocks/gui/blocks/sources/chirp.py | 9 + pySimBlocks/gui/blocks/sources/constant.py | 9 + pySimBlocks/gui/blocks/sources/file_source.py | 55 ++++- .../gui/blocks/sources/function_source.py | 54 ++++- pySimBlocks/gui/blocks/sources/ramp.py | 9 + pySimBlocks/gui/blocks/sources/sinusoidal.py | 9 + pySimBlocks/gui/blocks/sources/step.py | 9 + pySimBlocks/gui/blocks/sources/white_noise.py | 9 + .../gui/blocks/systems/linear_state_space.py | 9 + .../blocks/systems/non_linear_state_space.py | 54 ++++- .../blocks/systems/polytopic_state_space.py | 9 + .../blocks/systems/sofa/sofa_exchange_i_o.py | 54 ++++- .../gui/blocks/systems/sofa/sofa_plant.py | 54 ++++- 36 files changed, 724 insertions(+), 138 deletions(-) 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..d3777d3 100644 --- a/pySimBlocks/gui/blocks/block_meta.py +++ b/pySimBlocks/gui/blocks/block_meta.py @@ -47,47 +47,21 @@ class BlockMeta(ABC): - - """ - 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=... - ), - ] + """Define the GUI metadata contract for one block type. + + Subclasses declare static block metadata, optional dialog customizations, + and dynamic port-resolution rules used by the GUI layer. + + Attributes: + name: User-facing block name. + category: GUI block category. + type: Stable block type identifier. + summary: Short summary displayed in the GUI. + description: Rich description displayed in the dialog. + doc_path: Optional documentation file path. + parameters: Declared block parameters. + inputs: Declared input port metadata. + outputs: Declared output port metadata. """ @@ -105,49 +79,78 @@ def __init__(self): 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 +161,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 +190,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 +241,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 +262,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 +296,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 +313,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 +335,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 +351,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 +367,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 +402,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 +417,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..90a3da4 100644 --- a/pySimBlocks/gui/blocks/parameter_meta.py +++ b/pySimBlocks/gui/blocks/parameter_meta.py @@ -25,6 +25,17 @@ @dataclass(frozen=True) class ParameterMeta: + """Describe one configurable parameter of a GUI block. + + Attributes: + name: Parameter name. + type: User-facing parameter type description. + required: Whether the parameter must be provided. + autofill: Whether a default value should be inserted automatically. + default: Default parameter value. + enum: Allowed values for enum-like parameters. + description: Optional help text displayed in the GUI. + """ name: str type: str required: bool = False diff --git a/pySimBlocks/gui/blocks/port_meta.py b/pySimBlocks/gui/blocks/port_meta.py index d056e39..25fc195 100644 --- a/pySimBlocks/gui/blocks/port_meta.py +++ b/pySimBlocks/gui/blocks/port_meta.py @@ -25,6 +25,14 @@ @dataclass(frozen=True) class PortMeta: + """Describe one declared input or output port of a GUI block. + + Attributes: + name: Internal port name. + display_as: User-facing port label. + shape: Symbolic shape description shown in metadata. + description: Optional help text displayed in the GUI. + """ name: str display_as: str shape: list[Any] 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 From e1f56b37c6240ed75dd8b5278aef7d986aff6c64 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 13 Mar 2026 18:36:46 +0100 Subject: [PATCH 19/33] fix(docs): config docstring --- pySimBlocks/core/config.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pySimBlocks/core/config.py b/pySimBlocks/core/config.py index bfb0ac0..4463280 100644 --- a/pySimBlocks/core/config.py +++ b/pySimBlocks/core/config.py @@ -31,21 +31,24 @@ class SimulationConfig: Contains only execution-related parameters. Must not hold any model or block-specific information. - - Attributes: - dt: Simulation time step in seconds. - T: Simulation end time in seconds. - t0: Simulation start time in seconds. - solver: Integration scheme, either ``"fixed"`` or ``"variable"``. - logging: List of signal names to log during simulation. - clock: Clock source, either ``"internal"`` or ``"external"``. """ - + + #: 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 source, either ``"internal"`` or ``"external"``. clock: str = "internal" def validate(self) -> None: From b2bc71581546c7d82f24f073d0ae07907ffe9c2c Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 02:15:53 +0100 Subject: [PATCH 20/33] feat(docs): sphinx api reference v1 --- docs/Makefile | 11 ++ docs/source/_ext/api_generator.py | 296 ++++++++++++++++++++++++++++++ docs/source/_static/custom.css | 21 +++ docs/source/conf.py | 79 ++++++++ docs/source/index.rst | 14 ++ 5 files changed, 421 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/source/_ext/api_generator.py create mode 100644 docs/source/_static/custom.css create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst 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/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..c92c342 --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,21 @@ +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; +} diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..67a2290 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +import sys +import types + +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 = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +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 = [] + +html_theme = "furo" +html_title = "pySimBlocks Documentation" +html_static_path = ["_static"] +html_css_files = ["custom.css"] + + +def setup(app): + from api_generator import generate_api + + app.connect("builder-inited", generate_api) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..32f9be6 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,14 @@ +pySimBlocks Documentation +========================= + +pySimBlocks is a block-diagram simulation library with a Python API and a graphical editor. + +This documentation starts with a structured API reference for the code in ``pySimBlocks/``. +The existing guides in ``docs/User_Guide`` and ``docs/Developer_Guide`` are kept unchanged +and can be integrated later without changing this API layout. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + api/index From 26911c7e98b45287e1886f5c10bb70ddf037a552 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 02:27:21 +0100 Subject: [PATCH 21/33] fix(docs): docstring class attribute remove duplication --- pySimBlocks/core/block_source.py | 4 ---- pySimBlocks/core/config.py | 7 ++----- pySimBlocks/gui/blocks/block_meta.py | 20 ++++++++---------- pySimBlocks/gui/blocks/parameter_meta.py | 26 ++++++++++++++---------- pySimBlocks/gui/blocks/port_meta.py | 17 ++++++++-------- pySimBlocks/gui/graphics/theme.py | 25 ++++++++++------------- pySimBlocks/gui/project_controller.py | 3 +-- 7 files changed, 47 insertions(+), 55 deletions(-) diff --git a/pySimBlocks/core/block_source.py b/pySimBlocks/core/block_source.py index 5ee6652..b6b7bf5 100644 --- a/pySimBlocks/core/block_source.py +++ b/pySimBlocks/core/block_source.py @@ -29,10 +29,6 @@ class BlockSource(Block): 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. - - Attributes: - direct_feedthrough: Always False for sources. - is_source: Always True for sources. """ direct_feedthrough = False diff --git a/pySimBlocks/core/config.py b/pySimBlocks/core/config.py index 4463280..b160beb 100644 --- a/pySimBlocks/core/config.py +++ b/pySimBlocks/core/config.py @@ -84,13 +84,10 @@ class PlotConfig: Describes how logged signals should be visualized. Contains no plotting logic. - - Attributes: - plots: List of plot descriptors. Each descriptor is a dict that - must contain at least a ``"signals"`` key with a list of - signal names. """ + #: 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: diff --git a/pySimBlocks/gui/blocks/block_meta.py b/pySimBlocks/gui/blocks/block_meta.py index d3777d3..04016c8 100644 --- a/pySimBlocks/gui/blocks/block_meta.py +++ b/pySimBlocks/gui/blocks/block_meta.py @@ -51,31 +51,29 @@ class BlockMeta(ABC): Subclasses declare static block metadata, optional dialog customizations, and dynamic port-resolution rules used by the GUI layer. - - Attributes: - name: User-facing block name. - category: GUI block category. - type: Stable block type identifier. - summary: Short summary displayed in the GUI. - description: Rich description displayed in the dialog. - doc_path: Optional documentation file path. - parameters: Declared block parameters. - inputs: Declared input port metadata. - outputs: Declared output port metadata. """ # ----------- 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] = () # -------------------------------------------------------------------------- diff --git a/pySimBlocks/gui/blocks/parameter_meta.py b/pySimBlocks/gui/blocks/parameter_meta.py index 90a3da4..8ba87cb 100644 --- a/pySimBlocks/gui/blocks/parameter_meta.py +++ b/pySimBlocks/gui/blocks/parameter_meta.py @@ -25,21 +25,25 @@ @dataclass(frozen=True) class ParameterMeta: - """Describe one configurable parameter of a GUI block. - - Attributes: - name: Parameter name. - type: User-facing parameter type description. - required: Whether the parameter must be provided. - autofill: Whether a default value should be inserted automatically. - default: Default parameter value. - enum: Allowed values for enum-like parameters. - description: Optional help text displayed in the GUI. - """ + """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 25fc195..486d576 100644 --- a/pySimBlocks/gui/blocks/port_meta.py +++ b/pySimBlocks/gui/blocks/port_meta.py @@ -25,15 +25,16 @@ @dataclass(frozen=True) class PortMeta: - """Describe one declared input or output port of a GUI block. - - Attributes: - name: Internal port name. - display_as: User-facing port label. - shape: Symbolic shape description shown in metadata. - description: Optional help text displayed in the GUI. - """ + """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/graphics/theme.py b/pySimBlocks/gui/graphics/theme.py index ee1f248..ad2428d 100644 --- a/pySimBlocks/gui/graphics/theme.py +++ b/pySimBlocks/gui/graphics/theme.py @@ -59,33 +59,30 @@ 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. - - Attributes: - scene_bg: Scene background color. - block_bg: Default block background color. - block_bg_selected: Selected block background color. - block_border: Default block border color. - block_border_selected: Selected block border color. - text: Default text color. - text_selected: Selected text color. - wire: Connection wire color. - port_in: Input port color. - port_out: Output port color. - """ + """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 diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index acd6afd..a1cad85 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -47,8 +47,6 @@ class ProjectController(QObject): updates. Attributes: - dirty_changed: Signal emitted with the new dirty flag value whenever - the unsaved-changes state changes. project_state: Shared mutable state of the open project. view: The diagram canvas widget. resolve_block_meta: Callable returning :class:`BlockMeta` for a given @@ -56,6 +54,7 @@ class ProjectController(QObject): 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__( From a55152c73b77ae80d2db195dfec53bca7b8e16bc Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 02:27:52 +0100 Subject: [PATCH 22/33] fix(docs): add docs directory to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 4241acf..7db280e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .temp/ +# docs +docs/_build/ +docs/source/api/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] From 7ee1bccc20faa5a738d9aec3ea7c6ca560692c48 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 03:14:16 +0100 Subject: [PATCH 23/33] feat(docs): sphinx quick start --- docs/source/conf.py | 12 +++++ docs/source/images/user_guide/gui_example.png | Bin 0 -> 40509 bytes .../images/user_guide/quick_example.png | Bin 0 -> 47199 bytes docs/source/index.rst | 1 + docs/source/user_guide/index.rst | 13 +++++ docs/source/user_guide/installation.md | 49 ++++++++++++++++++ docs/source/user_guide/quick_start.md | 38 ++++++++++++++ 7 files changed, 113 insertions(+) create mode 100644 docs/source/images/user_guide/gui_example.png create mode 100644 docs/source/images/user_guide/quick_example.png create mode 100644 docs/source/user_guide/index.rst create mode 100644 docs/source/user_guide/installation.md create mode 100644 docs/source/user_guide/quick_start.md diff --git a/docs/source/conf.py b/docs/source/conf.py index 67a2290..86d832f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,7 @@ def _noop(*args, **kwargs): release = "0.1.1" extensions = [ + "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.napoleon", @@ -53,6 +54,14 @@ def _noop(*args, **kwargs): 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 @@ -66,6 +75,9 @@ def _noop(*args, **kwargs): autodoc_member_order = "bysource" autodoc_mock_imports = [] +autodoc_type_aliases = { + "ArrayLike": "ArrayLike", +} html_theme = "furo" html_title = "pySimBlocks Documentation" diff --git a/docs/source/images/user_guide/gui_example.png b/docs/source/images/user_guide/gui_example.png new file mode 100644 index 0000000000000000000000000000000000000000..aee50a8349cd1cd1303a72e9195cf8bb497cbbdd GIT binary patch literal 40509 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/user_guide/quick_example.png b/docs/source/images/user_guide/quick_example.png new file mode 100644 index 0000000000000000000000000000000000000000..f9ecdacecb57427971a608f594b8d3787b737eb7 GIT binary patch literal 47199 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 Nf=emFieDP~{x1)xTJ!(_ literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index 32f9be6..d8dacc7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,4 +11,5 @@ and can be integrated later without changing this API layout. :maxdepth: 2 :caption: Contents + user_guide/index api/index diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst new file mode 100644 index 0000000..cccc950 --- /dev/null +++ b/docs/source/user_guide/index.rst @@ -0,0 +1,13 @@ +User Guide +========== + +This section is intended for practical usage documentation of ``pySimBlocks``. + +It currently starts with installation information. Quick start material and a +tutorial path can be added later. + +.. toctree:: + :maxdepth: 1 + + installation + quick_start 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..d51f615 --- /dev/null +++ b/docs/source/user_guide/quick_start.md @@ -0,0 +1,38 @@ +# 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) + +{download}`Download filter.py <../../../examples/quick_start/filter.py>` + +## 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 +``` + +{download}`Download project.yaml <../../../examples/quick_start/gui/project.yaml>` From 33388c98355f28a4425a17282f0bb3989d38d341 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 10:59:06 +0100 Subject: [PATCH 24/33] feat(docs): sphinx first tutorial --- .../user_guide/tutorial_1-block_diagram.png | Bin 0 -> 9485 bytes docs/source/user_guide/index.rst | 5 +- docs/source/user_guide/tutorial_1_python.md | 100 ++++++++++++++++++ docs/source/user_guide/tutorials.md | 31 ++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 docs/source/images/user_guide/tutorial_1-block_diagram.png create mode 100644 docs/source/user_guide/tutorial_1_python.md create mode 100644 docs/source/user_guide/tutorials.md diff --git a/docs/source/images/user_guide/tutorial_1-block_diagram.png b/docs/source/images/user_guide/tutorial_1-block_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..2d26ecab545f5cb6baab4304c5407f5b0e19b0b3 GIT binary patch literal 9485 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 F{{XJr1f2i? literal 0 HcmV?d00001 diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index cccc950..b66c999 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -3,11 +3,12 @@ User Guide This section is intended for practical usage documentation of ``pySimBlocks``. -It currently starts with installation information. Quick start material and a -tutorial path can be added later. +It currently starts with installation and a short quick start, then continues +with a progressive tutorial path. .. toctree:: :maxdepth: 1 installation quick_start + tutorials diff --git a/docs/source/user_guide/tutorial_1_python.md b/docs/source/user_guide/tutorial_1_python.md new file mode 100644 index 0000000..0db60c1 --- /dev/null +++ b/docs/source/user_guide/tutorial_1_python.md @@ -0,0 +1,100 @@ +# 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. + +## 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 + +The full example is available in +[`examples/tutorials/tutorial_1_python/main.py`](../../../examples/tutorials/tutorial_1_python/main.py). + +```{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.md b/docs/source/user_guide/tutorials.md new file mode 100644 index 0000000..c8f48b6 --- /dev/null +++ b/docs/source/user_guide/tutorials.md @@ -0,0 +1,31 @@ +# 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. + +For now, the tutorial path starts with the Python API workflow. + +## 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. + +```{toctree} +:maxdepth: 1 + +tutorial_1_python +``` From 05bebef69730a2322eb16ab7ab5058276bdad9e5 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 18:26:51 +0100 Subject: [PATCH 25/33] feat(docs): sofa tutorial --- docs/User_Guide/getting_started.md | 54 --- docs/User_Guide/images/gui_example.png | Bin 40509 -> 0 bytes docs/User_Guide/images/quick_example.png | Bin 47199 -> 0 bytes .../images/tutorial_1-block_diagram.png | Bin 9485 -> 0 bytes docs/User_Guide/tutorial_1_python.md | 190 ---------- docs/User_Guide/tutorial_2_gui.md | 190 ---------- docs/User_Guide/tutorial_3_sofa.md | 354 ------------------ docs/source/_static/custom.css | 5 + .../source/_templates/sidebar/navigation.html | 41 ++ docs/source/conf.py | 22 ++ .../user_guide}/tutorial_2-block_dialog.gif | Bin .../user_guide}/tutorial_2-connections.gif | Bin .../user_guide}/tutorial_2-drag_drop.gif | Bin .../user_guide}/tutorial_2-gui_main.png | Bin .../images/user_guide}/tutorial_2-plots.gif | Bin .../images/user_guide}/tutorial_2-setting.gif | Bin .../user_guide}/tutorial_3-block_diagram.png | Bin .../tutorial_3-loop_psb_master.png | Bin .../tutorial_3-loop_sofa_master.png | Bin .../user_guide}/tutorial_3-run_psb_master.gif | Bin .../tutorial_3-run_sofa_master.gif | Bin .../user_guide}/tutorial_3-sofa_block.gif | Bin .../tutorial_3-sofa_gui_enhanced.gif | Bin .../user_guide}/tutorial_3-sofa_scene.png | Bin docs/source/user_guide/index.rst | 5 +- docs/source/user_guide/tutorials.md | 31 -- docs/source/user_guide/tutorials/index.md | 68 ++++ .../{ => tutorials}/tutorial_1_python.md | 19 +- .../user_guide/tutorials/tutorial_2_gui.md | 205 ++++++++++ .../user_guide/tutorials/tutorial_3_sofa.md | 186 +++++++++ 30 files changed, 544 insertions(+), 826 deletions(-) delete mode 100644 docs/User_Guide/getting_started.md delete mode 100644 docs/User_Guide/images/gui_example.png delete mode 100644 docs/User_Guide/images/quick_example.png delete mode 100644 docs/User_Guide/images/tutorial_1-block_diagram.png delete mode 100644 docs/User_Guide/tutorial_1_python.md delete mode 100644 docs/User_Guide/tutorial_2_gui.md delete mode 100644 docs/User_Guide/tutorial_3_sofa.md create mode 100644 docs/source/_templates/sidebar/navigation.html rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-block_dialog.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-connections.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-drag_drop.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-gui_main.png (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-plots.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_2-setting.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-block_diagram.png (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-loop_psb_master.png (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-loop_sofa_master.png (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-run_psb_master.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-run_sofa_master.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-sofa_block.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-sofa_gui_enhanced.gif (100%) rename docs/{User_Guide/images => source/images/user_guide}/tutorial_3-sofa_scene.png (100%) delete mode 100644 docs/source/user_guide/tutorials.md create mode 100644 docs/source/user_guide/tutorials/index.md rename docs/source/user_guide/{ => tutorials}/tutorial_1_python.md (80%) create mode 100644 docs/source/user_guide/tutorials/tutorial_2_gui.md create mode 100644 docs/source/user_guide/tutorials/tutorial_3_sofa.md 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/images/gui_example.png b/docs/User_Guide/images/gui_example.png deleted file mode 100644 index aee50a8349cd1cd1303a72e9195cf8bb497cbbdd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40509 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 z 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 diff --git a/docs/User_Guide/images/quick_example.png b/docs/User_Guide/images/quick_example.png deleted file mode 100644 index f9ecdacecb57427971a608f594b8d3787b737eb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47199 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 Nf=emFieDP~{x1)xTJ!(_ diff --git a/docs/User_Guide/images/tutorial_1-block_diagram.png b/docs/User_Guide/images/tutorial_1-block_diagram.png deleted file mode 100644 index 2d26ecab545f5cb6baab4304c5407f5b0e19b0b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9485 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 F{{XJr1f2i? 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/_static/custom.css b/docs/source/_static/custom.css index c92c342..ee8222a 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -19,3 +19,8 @@ div.related { 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..cfc584a --- /dev/null +++ b/docs/source/_templates/sidebar/navigation.html @@ -0,0 +1,41 @@ + diff --git a/docs/source/conf.py b/docs/source/conf.py index 86d832f..6ee579b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,6 +3,8 @@ 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) @@ -85,7 +87,27 @@ def _noop(*args, **kwargs): 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/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/user_guide/index.rst b/docs/source/user_guide/index.rst index b66c999..30febef 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -7,8 +7,9 @@ It currently starts with installation and a short quick start, then continues with a progressive tutorial path. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 + :includehidden: installation quick_start - tutorials + tutorials/index diff --git a/docs/source/user_guide/tutorials.md b/docs/source/user_guide/tutorials.md deleted file mode 100644 index c8f48b6..0000000 --- a/docs/source/user_guide/tutorials.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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. - -For now, the tutorial path starts with the Python API workflow. - -## 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. - -```{toctree} -:maxdepth: 1 - -tutorial_1_python -``` 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/tutorial_1_python.md b/docs/source/user_guide/tutorials/tutorial_1_python.md similarity index 80% rename from docs/source/user_guide/tutorial_1_python.md rename to docs/source/user_guide/tutorials/tutorial_1_python.md index 0db60c1..c113bc7 100644 --- a/docs/source/user_guide/tutorial_1_python.md +++ b/docs/source/user_guide/tutorials/tutorial_1_python.md @@ -20,7 +20,7 @@ We build a simple closed-loop control system composed of three elements: - A PI controller - A first-order discrete-time linear plant -![Block diagram](../images/user_guide/tutorial_1-block_diagram.png) +![Block diagram](../../images/user_guide/tutorial_1-block_diagram.png) The plant is a discrete-time first-order linear system defined by: @@ -48,12 +48,21 @@ $$ This integral action removes steady-state error for a step reference. -## Complete Example +## 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) -The full example is available in -[`examples/tutorials/tutorial_1_python/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>` + +## Complete Example -```{literalinclude} ../../../examples/tutorials/tutorial_1_python/main.py +```{literalinclude} ../../../../examples/tutorials/tutorial_1_python/main.py :language: python :caption: examples/tutorials/tutorial_1_python/main.py ``` 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..da4d63e --- /dev/null +++ b/docs/source/user_guide/tutorials/tutorial_2_gui.md @@ -0,0 +1,205 @@ +# 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. + +## Example Files + +The full example lives in `examples/tutorials/tutorial_2_gui/`. +The main file is: + +- [`project.yaml`](../../../../examples/tutorials/tutorial_2_gui/project.yaml) + +If you are reading the published documentation, you can download the example +file directly: + +{download}`Download project.yaml <../../../../examples/tutorials/tutorial_2_gui/project.yaml>` + +## 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 +``` + +## 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..5ebbc85 --- /dev/null +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -0,0 +1,186 @@ +# 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. + +## 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) + +## Example Files + +The full example lives in `examples/tutorials/tutorial_3_sofa/`. +The main files are: + +- [`project.yaml`](../../../../examples/tutorials/tutorial_3_sofa/project.yaml) +- [`finger/Finger.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/Finger.py) +- [`finger/FingerController.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py) + +If you are reading the published documentation, you can download the complete +example folder as an archive: + +{download}`Download tutorial_3_sofa.zip <../../_static/downloads/tutorial_3_sofa.zip>` + +## 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_2_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 From c90d2acf4b31e86ea58eeea3a0f8e9caf2440fec Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Mar 2026 18:27:00 +0100 Subject: [PATCH 26/33] feat(docs): sofa tutorial --- README.md | 12 +++---- .../tutorials/tutorial_2_gui/project.yaml | 31 +++++++++++++++++++ .../finger/FingerController.py | 3 +- 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 examples/tutorials/tutorial_2_gui/project.yaml 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/examples/tutorials/tutorial_2_gui/project.yaml b/examples/tutorials/tutorial_2_gui/project.yaml new file mode 100644 index 0000000..50c34c3 --- /dev/null +++ b/examples/tutorials/tutorial_2_gui/project.yaml @@ -0,0 +1,31 @@ +schema_version: 1 +project: + name: tutorial_2_gui + metadata: + created_by: pySimBlocks + created_at: '2026-02-18T00:00:00Z' +simulation: + dt: 0.1 + T: 10.0 + solver: fixed + logging: [] + plots: [] +diagram: + blocks: + - name: LinearStateSpace + category: systems + type: linear_state_space + parameters: + A: [[0.99]] + B: [[1.0]] + C: [[1.0]] + connections: [] +gui: + layout: + blocks: + LinearStateSpace: + x: -51.0 + y: -73.0 + orientation: normal + width: 120.0 + height: 60.0 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()] From 0601aeac9d5575b2506c284d204f64000382e05a Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sun, 15 Mar 2026 12:15:54 +0100 Subject: [PATCH 27/33] fix(docs): format tutorials reference links --- docs/source/user_guide/quick_start.md | 9 +- .../user_guide/tutorials/tutorial_1_python.md | 24 +++--- .../user_guide/tutorials/tutorial_2_gui.md | 27 +++--- .../user_guide/tutorials/tutorial_3_sofa.md | 29 ++++--- .../tutorials/tutorial_2_gui/project.yaml | 86 ++++++++++++++++--- examples/tutorials/tutorial_2_gui/run.py | 14 +++ 6 files changed, 137 insertions(+), 52 deletions(-) create mode 100644 examples/tutorials/tutorial_2_gui/run.py diff --git a/docs/source/user_guide/quick_start.md b/docs/source/user_guide/quick_start.md index d51f615..031fe32 100644 --- a/docs/source/user_guide/quick_start.md +++ b/docs/source/user_guide/quick_start.md @@ -21,8 +21,6 @@ The resulting plot should look like this: ![Noise filtered](../images/user_guide/quick_example.png) -{download}`Download filter.py <../../../examples/quick_start/filter.py>` - ## Graphical editor The same model can also be created visually with the graphical editor. @@ -35,4 +33,9 @@ To open the graphical editor with the quick-start project: pysimblocks gui examples/quick_start/gui ``` -{download}`Download project.yaml <../../../examples/quick_start/gui/project.yaml>` +## 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/tutorial_1_python.md b/docs/source/user_guide/tutorials/tutorial_1_python.md index c113bc7..9868cef 100644 --- a/docs/source/user_guide/tutorials/tutorial_1_python.md +++ b/docs/source/user_guide/tutorials/tutorial_1_python.md @@ -12,6 +12,18 @@ This tutorial introduces the core concepts of `pySimBlocks`: By the end of this tutorial, you will be able to build and simulate your own block-based model in Python. +## Example Files + +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: @@ -48,18 +60,6 @@ $$ This integral action removes steady-state error for a step reference. -## 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>` - ## Complete Example ```{literalinclude} ../../../../examples/tutorials/tutorial_1_python/main.py diff --git a/docs/source/user_guide/tutorials/tutorial_2_gui.md b/docs/source/user_guide/tutorials/tutorial_2_gui.md index da4d63e..efb2728 100644 --- a/docs/source/user_guide/tutorials/tutorial_2_gui.md +++ b/docs/source/user_guide/tutorials/tutorial_2_gui.md @@ -133,18 +133,6 @@ Then open the plot dialog to either: Under the hood, the GUI generates the same `Model` structure used in [Tutorial 1](./tutorial_1_python.md). The execution engine is the same. -## Example Files - -The full example lives in `examples/tutorials/tutorial_2_gui/`. -The main file is: - -- [`project.yaml`](../../../../examples/tutorials/tutorial_2_gui/project.yaml) - -If you are reading the published documentation, you can download the example -file directly: - -{download}`Download project.yaml <../../../../examples/tutorials/tutorial_2_gui/project.yaml>` - ## Save and Export Project Files ### Save the Project @@ -172,6 +160,21 @@ the simulation from the command line: 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 diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md index 5ebbc85..c475980 100644 --- a/docs/source/user_guide/tutorials/tutorial_3_sofa.md +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -16,6 +16,21 @@ The objective is to: By the end of this tutorial, you will be able to connect a `pySimBlocks` control loop to a SOFA simulation. +## Required Files + +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: @@ -31,20 +46,6 @@ vertical fingertip position. ![SOFA scene](../../images/user_guide/tutorial_3-sofa_scene.png) -## Example Files - -The full example lives in `examples/tutorials/tutorial_3_sofa/`. -The main files are: - -- [`project.yaml`](../../../../examples/tutorials/tutorial_3_sofa/project.yaml) -- [`finger/Finger.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/Finger.py) -- [`finger/FingerController.py`](../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py) - -If you are reading the published documentation, you can download the complete -example folder as an archive: - -{download}`Download tutorial_3_sofa.zip <../../_static/downloads/tutorial_3_sofa.zip>` - ## Prerequisites Before coupling `pySimBlocks` with SOFA, make sure: diff --git a/examples/tutorials/tutorial_2_gui/project.yaml b/examples/tutorials/tutorial_2_gui/project.yaml index 50c34c3..188c5e1 100644 --- a/examples/tutorials/tutorial_2_gui/project.yaml +++ b/examples/tutorials/tutorial_2_gui/project.yaml @@ -5,27 +5,91 @@ project: created_by: pySimBlocks created_at: '2026-02-18T00:00:00Z' simulation: - dt: 0.1 - T: 10.0 + dt: 0.01 + T: 5.0 solver: fixed - logging: [] - plots: [] + 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: LinearStateSpace + - name: system category: systems type: linear_state_space parameters: - A: [[0.99]] - B: [[1.0]] + A: [[0.9]] + B: [[0.5]] C: [[1.0]] - connections: [] + 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: - LinearStateSpace: - x: -51.0 - y: -73.0 + 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) From b81c71298d5986ba90c113612dd78bd989a5336a Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sun, 15 Mar 2026 12:16:57 +0100 Subject: [PATCH 28/33] fix(docs): add links and image to homepag --- docs/source/index.rst | 51 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index d8dacc7..9828330 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,11 +1,54 @@ pySimBlocks Documentation ========================= -pySimBlocks is a block-diagram simulation library with a Python API and a graphical editor. +.. raw:: html -This documentation starts with a structured API reference for the code in ``pySimBlocks/``. -The existing guides in ``docs/User_Guide`` and ``docs/Developer_Guide`` are kept unchanged -and can be integrated later without changing this API layout. +
+ 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/gui_example.png + :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 From feeb246b87ca805c037c1441a424cf922ecb8563 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sun, 15 Mar 2026 12:25:16 +0100 Subject: [PATCH 29/33] feat(docs): add cli documentation --- .../source/_templates/sidebar/navigation.html | 38 --------- docs/source/user_guide/cli.md | 83 +++++++++++++++++++ docs/source/user_guide/index.rst | 1 + 3 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 docs/source/user_guide/cli.md diff --git a/docs/source/_templates/sidebar/navigation.html b/docs/source/_templates/sidebar/navigation.html index cfc584a..eda5176 100644 --- a/docs/source/_templates/sidebar/navigation.html +++ b/docs/source/_templates/sidebar/navigation.html @@ -1,41 +1,3 @@ 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 index 30febef..ce528fd 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -12,4 +12,5 @@ with a progressive tutorial path. installation quick_start + cli tutorials/index From 80aa77d569d8ffa52babe311e35179fbb8587509 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 16 Mar 2026 14:58:14 +0100 Subject: [PATCH 30/33] fix(docs): git on homepage --- docs/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 9828330..6dcc4e1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,7 +14,7 @@ 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/gui_example.png +.. image:: images/user_guide/tutorial_2-setting.gif :alt: pySimBlocks graphical editor :align: center :width: 85% From 9664d9f8c55e04803cfc88650607ee0ef7664074 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 16 Mar 2026 22:19:13 +0100 Subject: [PATCH 31/33] fix(docs): add docs install in pyproject --- .gitignore | 4 ++++ pyproject.toml | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 7db280e..16893d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .temp/ +# CLAUDE +.claudeignore +CLAUDE.md + # docs docs/_build/ docs/source/api/ 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", From d65d0f37ddfbdbab828b63ab61ccb83c810424f6 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 16 Mar 2026 22:21:24 +0100 Subject: [PATCH 32/33] feat(docs): readthedocs file --- .readthedocs.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .readthedocs.yaml 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 From 60382b5d5fdc7f1cb35a050127853e7cda35a2a6 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Mon, 16 Mar 2026 22:47:51 +0100 Subject: [PATCH 33/33] fix(docs): tutorial path fix --- docs/source/user_guide/tutorials/tutorial_3_sofa.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/user_guide/tutorials/tutorial_3_sofa.md b/docs/source/user_guide/tutorials/tutorial_3_sofa.md index c475980..fd96c1d 100644 --- a/docs/source/user_guide/tutorials/tutorial_3_sofa.md +++ b/docs/source/user_guide/tutorials/tutorial_3_sofa.md @@ -116,7 +116,7 @@ It must implement: The tutorial example uses the following controller: ```{literalinclude} ../../../../examples/tutorials/tutorial_3_sofa/finger/FingerController.py :language: python -:caption: examples/tutorials/tutorial_2_sofa/finger/FingerController.py +:caption: examples/tutorials/tutorial_3_sofa/finger/FingerController.py ``` The scene must instantiate that controller and return it from `createScene(...)`