diff --git a/pySimBlocks/blocks/sources/file_source.py b/pySimBlocks/blocks/sources/file_source.py
index af70290..2f87c3e 100644
--- a/pySimBlocks/blocks/sources/file_source.py
+++ b/pySimBlocks/blocks/sources/file_source.py
@@ -38,9 +38,12 @@ class FileSource(BlockSource):
- ``.npz`` / ``.npy``: 1D ``(N,)`` treated as ``(N, 1)``, or 2D ``(N, n)``
where N is the number of samples and n the signal dimension. Each step
- outputs a ``(n, 1)`` column vector.
+ outputs a ``(n, 1)`` column vector. If the stored array is instead
+ ``(n, N)`` (signals in rows, samples in columns), set ``transpose=True``
+ to have it transposed to ``(N, n)`` before use.
- ``.csv``: a single column is selected by ``key``, always producing shape
- ``(N, 1)``. Output per step is ``(1, 1)``.
+ ``(N, 1)``. Output per step is ``(1, 1)``. ``transpose`` is not
+ applicable to CSV input.
Alternatively, when ``use_time=True``, the output is selected by
looking up the closest past timestamp in a time column bundled with
@@ -52,6 +55,8 @@ class FileSource(BlockSource):
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.
+ transpose: If True (NPZ/NPY only), transpose the loaded 2D array
+ before use, i.e. treat it as ``(n, N)`` instead of ``(N, n)``.
"""
VALID_FILE_TYPES = {"npz", "npy", "csv"}
@@ -63,6 +68,7 @@ def __init__(
key: str | None = None,
repeat: bool = False,
use_time: bool = False,
+ transpose: bool = False,
sample_time: float | None = None,
):
"""Initialize a FileSource block.
@@ -77,13 +83,18 @@ def __init__(
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.
+ transpose: If True, transpose the loaded 2D array before use.
+ Only supported for NPZ and NPY inputs, where it allows
+ loading data stored as ``(n, N)`` (signals in rows) instead
+ of the expected ``(N, n)`` (samples in rows).
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.
+ is combined with an NPY file or with ``repeat=True``, if
+ ``transpose`` is combined with a CSV file, or if the loaded
+ data is invalid.
FileNotFoundError: If the file does not exist.
"""
super().__init__(name, sample_time)
@@ -93,6 +104,7 @@ def __init__(
self.key = key
self.repeat = self._to_bool(repeat, "repeat")
self.use_time = self._to_bool(use_time, "use_time")
+ self.transpose = self._to_bool(transpose, "transpose")
if self.use_time and self.file_type == "npy":
raise ValueError(
@@ -102,6 +114,10 @@ def __init__(
raise ValueError(
f"[{self.name}] repeat cannot be used when use_time=True."
)
+ if self.transpose and self.file_type == "csv":
+ raise ValueError(
+ f"[{self.name}] transpose is supported only for NPZ and NPY inputs."
+ )
self._time: np.ndarray | None = None
self._samples = self._load_samples()
@@ -198,7 +214,10 @@ def _load_samples(self) -> np.ndarray:
if arr.ndim == 1:
arr = arr.reshape(-1, 1)
- elif arr.ndim != 2:
+ elif arr.ndim == 2:
+ if self.transpose:
+ arr = arr.T
+ else:
raise ValueError(
f"[{self.name}] Loaded data must be 1D or 2D. Got shape {arr.shape}."
)
@@ -206,6 +225,12 @@ def _load_samples(self) -> np.ndarray:
if arr.shape[0] == 0:
raise ValueError(f"[{self.name}] Loaded file contains no samples.")
+ if time is not None and time.shape[0] != arr.shape[0]:
+ raise ValueError(
+ f"[{self.name}] time length ({time.shape[0]}) must match number "
+ f"of samples ({arr.shape[0]}) after applying transpose."
+ )
+
self._time = time
return arr.astype(float, copy=False)
diff --git a/pySimBlocks/blocks/systems/linear_state_space.py b/pySimBlocks/blocks/systems/linear_state_space.py
index 7a5cd08..28206a4 100644
--- a/pySimBlocks/blocks/systems/linear_state_space.py
+++ b/pySimBlocks/blocks/systems/linear_state_space.py
@@ -29,20 +29,25 @@
class LinearStateSpace(Block):
"""Discrete-time linear state-space system block.
- Implements a strictly proper discrete-time linear system:
+ Implements a 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.
+ y[k] = C x[k] (if D is None)
+ y[k] = C x[k] + D u[k] (if D is provided)
+
+ When D is None the block is strictly proper (no direct feedthrough).
+ When D is provided the block has direct feedthrough and algebraic loops
+ involving this block will be detected and rejected at compile time.
Attributes:
A: State transition matrix of shape (n, n).
B: Input matrix of shape (n, m).
C: Output matrix of shape (p, n).
+ D: Feedthrough matrix of shape (p, m), or None.
"""
+ # Default at class level; overridden per-instance when D is provided.
direct_feedthrough = False
def __init__(
@@ -51,6 +56,7 @@ def __init__(
A: ArrayLike,
B: ArrayLike,
C: ArrayLike,
+ D: ArrayLike | None = None,
x0: ArrayLike | None = None,
sample_time: float | None = None,
):
@@ -61,6 +67,9 @@ def __init__(
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).
+ D: Feedthrough matrix, array-like of shape (p, m), or None.
+ When provided, the block gains direct feedthrough and
+ y[k] = C x[k] + D u[k].
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
@@ -101,6 +110,23 @@ def __init__(
self._m = self.B.shape[1]
self._p = self.C.shape[0]
+ # --- D matrix (optional) ---
+ if D is not None:
+ self.D = np.asarray(D, dtype=float)
+ if self.D.ndim != 2:
+ raise ValueError(f"[{self.name}] D must be 2D. Got shape {self.D.shape}.")
+ if self.D.shape != (self._p, self._m):
+ raise ValueError(
+ f"[{self.name}] D must have shape ({self._p}, {self._m}). "
+ f"Got {self.D.shape}."
+ )
+ # Override direct_feedthrough at the instance level so the
+ # scheduler sees this block as having direct feedthrough.
+ self.direct_feedthrough = True
+ else:
+ self.D = None
+
+ # --- Initial state ---
if x0 is None:
x0_arr = np.zeros((n, 1), dtype=float)
else:
@@ -143,12 +169,21 @@ def initialize(self, t0: float) -> None:
def output_update(self, t: float, dt: float) -> None:
"""Compute y and x outputs from the committed state.
+ When D is provided, u[k] is read at this step (direct feedthrough).
+
Args:
t: Current simulation time in seconds.
dt: Current time step in seconds.
"""
x = self.state["x"]
- self.outputs["y"] = self.C @ x
+ if self.D is not None:
+ u = self.inputs["u"]
+ if u is None:
+ raise RuntimeError(f"[{self.name}] Input 'u' is not connected or not set.")
+ u_vec = self._to_col_vec("u", u, self._m)
+ self.outputs["y"] = self.C @ x + self.D @ u_vec
+ else:
+ self.outputs["y"] = self.C @ x
self.outputs["x"] = x.copy()
def state_update(self, t: float, dt: float) -> None:
diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py
index eb2b704..e8daff5 100644
--- a/pySimBlocks/blocks/systems/sofa/sofa_controller.py
+++ b/pySimBlocks/blocks/systems/sofa/sofa_controller.py
@@ -20,6 +20,8 @@
from __future__ import annotations
+import atexit
+import os
from pathlib import Path
from typing import Any, Dict, List
@@ -69,10 +71,11 @@ class SofaPysimBlocksController(Sofa.Core.Controller):
verbose: If True, print logged variables at each control step.
"""
- def __init__(self, name: str = "SofaControllerGui"):
+ def __init__(self, project_yaml: str, name: str = "SofaControllerGui"):
"""Initialize the SOFA–pySimBlocks controller.
Args:
+ project_yaml: Path to the pySimBlocks YAML project file.
name: Name passed to the SOFA controller base class.
"""
super().__init__(name=name)
@@ -91,9 +94,16 @@ def __init__(self, name: str = "SofaControllerGui"):
self.sim: Simulator | None = None
self.step_index: int = 0
- self.project_yaml: str | None = None
+ self.project_yaml: str = project_yaml
self._init_failed = False
+ print(f"[pySimBlocks] Controller using project_yaml: {project_yaml}")
+
+ # --- dump des logs, uniquement si lancé depuis pySimBlocks GUI ---
+ self._dump_logs_path: Path | None = None
+ if os.environ.get("PYSIMBLOCKS_SOFA_DUMP_LOGS") == "1" and project_yaml is not None:
+ self._dump_logs_path = Path(project_yaml).parent / ".sofa_logs.npz"
+ atexit.register(self._dump_logs)
# --------------------------------------------------------------------------
# Public methods
@@ -427,3 +437,19 @@ def _adapt_model_for_sofa(self, model_data: Dict[str, Any]) -> Dict[str, Any]:
adapted["blocks"] = adapted_blocks
return adapted
+
+ def _dump_logs(self) -> None:
+ """Serialize self.sim.logs to .npz at process exit (GUI-triggered runs only)."""
+ if self._dump_logs_path is None or self.sim is None:
+ return
+ try:
+ arrays = {}
+ for var in self.sim.logs:
+ if var == "time":
+ arrays["time"] = np.asarray(self.sim.logs["time"])
+ continue
+ arrays[var] = self.sim.get_data(variable=var)
+ np.savez(self._dump_logs_path, **arrays)
+ print(f"[pySimBlocks] Logs dumped to {self._dump_logs_path}")
+ except Exception as e:
+ print(f"[pySimBlocks] WARNING: failed to dump logs: {e}")
diff --git a/pySimBlocks/cli.py b/pySimBlocks/cli.py
index db53844..77a4e71 100644
--- a/pySimBlocks/cli.py
+++ b/pySimBlocks/cli.py
@@ -36,22 +36,12 @@ def _run_gui(project_dir: str | None) -> None:
def _run_export(args: argparse.Namespace) -> None:
+ from pySimBlocks.project import generate_run_script
+
project_yaml = Path(args.project_file) if args.project_file else None
project_dir = Path(args.project_dir) if args.project_dir else Path(".")
output = Path(args.out) if args.out else None
-
- if args.sofa_controller:
- try:
- from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller
- generate_sofa_controller(project_dir=project_dir, project_yaml=project_yaml)
- except Exception as e:
- print(f"Error generating SOFA controller: {e}")
- print("See SOFA integration documentation for troubleshooting.")
- sys.exit(1)
- else:
- from pySimBlocks.project import generate_run_script
-
- generate_run_script(project_dir=project_dir, project_yaml=project_yaml, output=output)
+ generate_run_script(project_dir=project_dir, project_yaml=project_yaml, output=output)
def _run_update() -> None:
@@ -61,6 +51,15 @@ def _run_update() -> None:
generate_blocks_index()
print("pySimBlocks update complete.")
+def _run_sofa_init(args: argparse.Namespace) -> None:
+ from pySimBlocks.project.generate_sofa_scaffold import generate_sofa_scaffold
+
+ generate_sofa_scaffold(
+ name=args.name,
+ output_dir=args.directory,
+ force=args.force,
+ )
+
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
@@ -98,11 +97,22 @@ def _build_parser() -> argparse.ArgumentParser:
help="Project directory containing project.yaml",
)
export_parser.add_argument("-o", "--out", help="Output run.py path")
- export_parser.add_argument(
- "-s",
- "--sofa-controller",
+
+ sofa_init_parser = subparsers.add_parser(
+ "sofa-init",
+ help="Generate a starter SOFA scene + controller pair.",
+ )
+ sofa_init_parser.add_argument("name", help="Base name for the generated files (e.g. 'finger').")
+ sofa_init_parser.add_argument(
+ "-d", "--directory",
+ dest="directory",
+ default=None,
+ help="Output directory. Defaults to the current directory.",
+ )
+ sofa_init_parser.add_argument(
+ "-f", "--force",
action="store_true",
- help="Update SOFA controller from project.yaml instead of generating run.py.",
+ help="Overwrite existing files.",
)
subparsers.add_parser("update", help="Regenerate pySimBlocks blocks index.")
@@ -119,6 +129,8 @@ def main(argv: list[str] | None = None) -> None:
_run_export(args)
elif args.command == "update":
_run_update()
+ elif args.command == "sofa-init":
+ _run_sofa_init(args)
else:
parser.print_help()
diff --git a/pySimBlocks/docs/blocks/sources/file_source.md b/pySimBlocks/docs/blocks/sources/file_source.md
index 58b0d76..c8edc93 100644
--- a/pySimBlocks/docs/blocks/sources/file_source.md
+++ b/pySimBlocks/docs/blocks/sources/file_source.md
@@ -19,6 +19,7 @@ Supported file formats:
| `key` | str | Mandatory for `*.npz` (array key) and `*.csv` (column name). Unused for `*.npy`. | True |
| `repeat` | bool | End-of-file behavior. If `false`, outputs zeros after the last sample. If `true`, restarts from the first sample. | True (default: `False`) |
| `use_time` | bool | If `true` (only for `*.npz` and `*.csv`), uses a `time` signal and applies ZOH: at time `t`, output sample at largest index `i` such that `T[i] <= t`. | True (default: `False`) |
+| `transpose` | bool | If `true` (only for `*.npz` and `*.npy`), transposes the loaded 2D array before use, i.e. treats data stored as `(n, N)` (signals in rows, samples in columns) as `(N, n)`. Not applicable to `*.csv`. | True (default: `False`) |
| `sample_time` | float | Block sample time. If omitted, global simulation step is used. | True |
---
@@ -41,10 +42,12 @@ None.
- File format is inferred from `file_path` extension (`.npz`, `.npy`, `.csv`).
- `npz` / `npy`: array must be 1D `(N,)` or 2D `(N, n)` — N samples, n signal dimension. Output per step: `(n, 1)`.
-- `csv`: `key` selects a single named column, always `(N, 1)`. Output per step: `(1, 1)`.
+ - If your array is stored as `(n, N)` instead (e.g. exported from MATLAB as `states x time`), set `transpose=true` to have it converted to `(N, n)` before use.
+- `csv`: `key` selects a single named column, always `(N, 1)`. Output per step: `(1, 1)`. `transpose` is not applicable.
- With `use_time=true`, `time` must exist and be strictly increasing.
- `npz`: requires key `time`.
- `csv`: requires column `time`.
+- `transpose` is applied before the `time` length check, so `time` must match the number of samples along the resulting first axis (after transpose, if enabled).
---
© 2026 Université de Lille & INRIA - Licensed under LGPL-3.0-or-later
diff --git a/pySimBlocks/docs/blocks/systems/linear_state_space.md b/pySimBlocks/docs/blocks/systems/linear_state_space.md
index e851d8b..c1c9314 100644
--- a/pySimBlocks/docs/blocks/systems/linear_state_space.md
+++ b/pySimBlocks/docs/blocks/systems/linear_state_space.md
@@ -2,13 +2,16 @@
## Summary
-The **LinearStateSpace** block implements a discrete-time linear state-space system without direct feedthrough.
+The **LinearStateSpace** block implements a discrete-time linear state-space system.
+
+Without feedthrough matrix $D$ the system is strictly proper. When $D$ is provided
+the block has direct feedthrough and $y[k]$ depends on $u[k]$ at the same step.
---
## Mathematical definition
-The system is defined by the equations:
+Without $D$ (strictly proper):
$$
x[k+1] = A x[k] + B u[k]
@@ -18,6 +21,16 @@ $$
y[k] = C x[k]
$$
+With $D$ (direct feedthrough):
+
+$$
+x[k+1] = A x[k] + B u[k]
+$$
+
+$$
+y[k] = C x[k] + D u[k]
+$$
+
where:
- $x[k]$ is the state vector,
- $u[k]$ is the input vector,
@@ -32,6 +45,7 @@ where:
| `A` | 2D array | State transition matrix of size (n, n). | False |
| `B` | 2D array | Input matrix of size (n, m). | False |
| `C` | 2D array | Output matrix of size (p, n). | False |
+| `D` | 2D array | Feedthrough matrix of size (p, m). If omitted, no direct feedthrough. | True |
| `x0` | 1D array | Initial state vector of size (n,). If omitted, the state is initialized to zero. | True |
| `sample_time` | float | Block sample time. If omitted, the global simulation time step is used. | True |
@@ -57,9 +71,12 @@ where:
## Notes
- The block has internal state.
-- The system is strictly proper (no direct feedthrough).
-- Matrix $D$ is intentionally not supported to avoid algebraic loops.
-- The output is computed from the current state.
+- When `D` is omitted or `None`, the system is strictly proper (no direct feedthrough).
+- When `D` is provided, the block has direct feedthrough: `y[k]` depends on `u[k]`
+ at the same simulation step.
+- A block with direct feedthrough cannot be part of a feedback loop without a `Delay`
+ block breaking the cycle — pySimBlocks will raise a `RuntimeError` at compile time
+ if an algebraic loop is detected.
---
diff --git a/pySimBlocks/gui/addons/sofa/slider_params_dialog.py b/pySimBlocks/gui/addons/sofa/slider_params_dialog.py
new file mode 100644
index 0000000..f939429
--- /dev/null
+++ b/pySimBlocks/gui/addons/sofa/slider_params_dialog.py
@@ -0,0 +1,343 @@
+# ******************************************************************************
+# pySimBlocks
+# Copyright (c) 2026 Université de Lille & INRIA
+# ******************************************************************************
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+# for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program. If not, see .
+# ******************************************************************************
+# Authors: see Authors.txt
+# ******************************************************************************
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Tuple
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QDialog,
+ QDoubleSpinBox,
+ QFormLayout,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+# Parameter types (see ParameterMeta.type) considered numeric enough to be
+# exposed as an ImGui slider at runtime.
+NUMERIC_PARAM_TYPES = {
+ "float",
+ "int",
+ "scalar",
+ "vector",
+ "matrix",
+ "scalar | vector | matrix",
+}
+
+# Block types excluded from the candidate list: sliding a SOFA I/O block's
+# own attributes does not make sense (it is the block driving the sliders).
+_EXCLUDED_BLOCK_TYPES = {"sofa_plant", "sofa_exchange_i_o"}
+
+
+def collect_slider_candidates(project_state) -> List[Tuple[str, str, str]]:
+ """List every block/parameter pair eligible as a SOFA slider.
+
+ Candidates are derived from static block metadata (``ParameterMeta``),
+ not from a live SOFA/pySimBlocks instance, so this works from the GUI
+ before any simulation is run.
+
+ Args:
+ project_state: Project state holding the current block instances.
+
+ Returns:
+ Sorted list of ``(block_name, param_name, description)`` tuples.
+ """
+ candidates: List[Tuple[str, str, str]] = []
+ for block in getattr(project_state, "blocks", []):
+ if block.meta.type in _EXCLUDED_BLOCK_TYPES:
+ continue
+ for pmeta in block.meta.parameters:
+ if pmeta.type not in NUMERIC_PARAM_TYPES:
+ continue
+ candidates.append((block.name, pmeta.name, pmeta.description))
+
+ candidates.sort(key=lambda c: (c[0].lower(), c[1].lower()))
+ return candidates
+
+
+class SliderParamsDialog(QDialog):
+ """Table-based editor for the ``slider_params`` SOFA parameter.
+
+ Lists every numeric parameter declared by the blocks of the current
+ project (from static metadata) and lets the user check the ones to
+ expose as ImGui sliders, together with a min/max range each. A filter
+ field narrows the list down when the project has many blocks.
+
+ Attributes:
+ project_state: Project state used to enumerate candidate variables.
+ table: Table widget listing candidate variables.
+ rows: Row widgets keyed by ``"block_name.param_name"``.
+ """
+
+ def __init__(
+ self,
+ project_state,
+ current_value: Dict[str, Any] | None,
+ parent=None,
+ ):
+ """Initialize the slider-params dialog.
+
+ Args:
+ project_state: Project state used to enumerate candidate variables.
+ current_value: Current ``slider_params`` dict, used to pre-check
+ rows and pre-fill their min/max values.
+ parent: Optional parent widget.
+
+ Raises:
+ None.
+ """
+ super().__init__(parent)
+ self.setWindowTitle("Configure SOFA sliders")
+ self.setMinimumWidth(480)
+
+ self.project_state = project_state
+ self._current_value = dict(current_value) if current_value else {}
+ self.rows: Dict[str, Dict[str, Any]] = {}
+
+ main_layout = QVBoxLayout(self)
+
+ self._build_filter_row(main_layout)
+ self._build_table(main_layout)
+ self._populate_table()
+ self._build_buttons_row(main_layout)
+
+ # Cap the dialog height for diagrams with many variables; the table
+ # itself scrolls for whatever does not fit.
+ row_count = max(self.table.rowCount(), 1)
+ self.resize(560, min(140 + 28 * row_count, 520))
+
+ # --------------------------------------------------------------------------
+ # Public Methods
+ # --------------------------------------------------------------------------
+
+ def slider_params(self) -> Dict[str, List[float]]:
+ """Build the ``slider_params`` dict from the checked rows.
+
+ Returns:
+ Mapping of ``"block_name.param_name"`` to ``[min, max]`` for
+ every row whose checkbox is checked.
+ """
+ result: Dict[str, List[float]] = {}
+ for key, widgets in self.rows.items():
+ if widgets["check"].isChecked():
+ result[key] = [widgets["min"].value(), widgets["max"].value()]
+ return result
+
+ def accept(self) -> None:
+ """Validate ranges before closing the dialog."""
+ for key, widgets in self.rows.items():
+ if not widgets["check"].isChecked():
+ continue
+ if widgets["min"].value() >= widgets["max"].value():
+ QMessageBox.warning(
+ self,
+ "Invalid range",
+ f"'{key}': min must be strictly less than max.",
+ )
+ return
+ super().accept()
+
+ # --------------------------------------------------------------------------
+ # Private Methods
+ # --------------------------------------------------------------------------
+
+ def _build_filter_row(self, layout: QVBoxLayout) -> None:
+ """Build the free-text filter row."""
+ filter_layout = QHBoxLayout()
+ filter_layout.addWidget(QLabel("Filter:"))
+ self.filter_edit = QLineEdit()
+ self.filter_edit.setPlaceholderText("Filter by block or parameter name...")
+ self.filter_edit.textChanged.connect(self._apply_filter)
+ filter_layout.addWidget(self.filter_edit)
+ layout.addLayout(filter_layout)
+
+ def _build_table(self, layout: QVBoxLayout) -> None:
+ """Build the empty candidate-variable table."""
+ self.table = QTableWidget(0, 3)
+ self.table.setHorizontalHeaderLabels(["", "Variable", "Range (min – max)"])
+ header = self.table.horizontalHeader()
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
+ header.setSectionResizeMode(1, QHeaderView.Stretch)
+ header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
+ self.table.verticalHeader().setVisible(False)
+ self.table.setEditTriggers(QTableWidget.NoEditTriggers)
+ layout.addWidget(self.table)
+
+ def _build_buttons_row(self, layout: QVBoxLayout) -> None:
+ """Build the Ok/Cancel button row."""
+ buttons_layout = QHBoxLayout()
+ buttons_layout.addStretch()
+
+ ok_btn = QPushButton("Ok")
+ ok_btn.setDefault(True)
+ ok_btn.clicked.connect(self.accept)
+ buttons_layout.addWidget(ok_btn)
+
+ cancel_btn = QPushButton("Cancel")
+ cancel_btn.clicked.connect(self.reject)
+ buttons_layout.addWidget(cancel_btn)
+
+ layout.addLayout(buttons_layout)
+
+ def _populate_table(self) -> None:
+ """Fill the table with one row per candidate variable."""
+ candidates = collect_slider_candidates(self.project_state)
+ self.table.setRowCount(len(candidates))
+
+ for row, (block_name, param_name, description) in enumerate(candidates):
+ key = f"{block_name}.{param_name}"
+ checked = key in self._current_value
+ bounds = self._current_value.get(key, [0.0, 1.0])
+
+ check = QCheckBox()
+ check.setChecked(checked)
+ check_container = QWidget()
+ check_layout = QHBoxLayout(check_container)
+ check_layout.setContentsMargins(0, 0, 0, 0)
+ check_layout.setAlignment(Qt.AlignCenter)
+ check_layout.addWidget(check)
+ self.table.setCellWidget(row, 0, check_container)
+
+ name_item = QTableWidgetItem(key)
+ name_item.setFlags(name_item.flags() & ~Qt.ItemIsEditable)
+ if description:
+ name_item.setToolTip(description)
+ self.table.setItem(row, 1, name_item)
+
+ range_widget = QWidget()
+ range_layout = QHBoxLayout(range_widget)
+ range_layout.setContentsMargins(0, 0, 0, 0)
+
+ min_spin = QDoubleSpinBox()
+ min_spin.setRange(-1e6, 1e6)
+ min_spin.setDecimals(4)
+ min_spin.setValue(float(bounds[0]))
+ min_spin.setEnabled(checked)
+
+ max_spin = QDoubleSpinBox()
+ max_spin.setRange(-1e6, 1e6)
+ max_spin.setDecimals(4)
+ max_spin.setValue(float(bounds[1]))
+ max_spin.setEnabled(checked)
+
+ range_layout.addWidget(min_spin)
+ range_layout.addWidget(QLabel("–"))
+ range_layout.addWidget(max_spin)
+ self.table.setCellWidget(row, 2, range_widget)
+
+ check.toggled.connect(min_spin.setEnabled)
+ check.toggled.connect(max_spin.setEnabled)
+
+ self.rows[key] = {
+ "check": check,
+ "min": min_spin,
+ "max": max_spin,
+ "block": block_name,
+ "param": param_name,
+ }
+
+ def _apply_filter(self, text: str) -> None:
+ """Hide rows whose variable name does not match the filter text."""
+ needle = text.strip().lower()
+ for row in range(self.table.rowCount()):
+ key = self.table.item(row, 1).text().lower()
+ self.table.setRowHidden(row, needle not in key)
+
+
+class SliderParamsRowMixin:
+ """Mixin adding a "Configure sliders..." row for the ``slider_params`` param.
+
+ Mix this into a ``BlockMeta`` subclass (before ``BlockMeta`` in the MRO)
+ and call :meth:`_build_slider_params_row` from ``build_param`` instead of
+ the generic ``_create_param_row`` for the ``slider_params`` parameter.
+ """
+
+ def _build_slider_params_row(
+ self,
+ session,
+ form: QFormLayout,
+ pmeta,
+ readonly: bool = False,
+ ) -> None:
+ """Build the summary label + "Configure..." button row.
+
+ Args:
+ session: Active dialog session.
+ form: Form layout receiving the widget.
+ pmeta: ``ParameterMeta`` for ``slider_params``.
+ readonly: Whether the dialog is read-only.
+ """
+ row_widget = QWidget()
+ row_layout = QHBoxLayout(row_widget)
+ row_layout.setContentsMargins(0, 0, 0, 0)
+
+ summary = QLabel()
+ configure_btn = QPushButton("Configure sliders...")
+ configure_btn.setEnabled(not readonly)
+ configure_btn.clicked.connect(
+ lambda: self._open_slider_params_dialog(session, summary)
+ )
+
+ row_layout.addWidget(summary, 1)
+ row_layout.addWidget(configure_btn)
+
+ label = QLabel(f"{pmeta.name}:")
+ if pmeta.description:
+ label.setToolTip(pmeta.description)
+
+ form.addRow(label, row_widget)
+ session.param_widgets[pmeta.name] = row_widget
+ session.param_labels[pmeta.name] = label
+
+ self._refresh_slider_summary(session, summary)
+
+ def _refresh_slider_summary(self, session, summary_label: QLabel) -> None:
+ """Update the summary label from the current local parameter state."""
+ value = session.local_params.get("slider_params") or {}
+ count = len(value) if isinstance(value, dict) else 0
+ summary_label.setText(
+ f"{count} variable(s) configured" if count else "No sliders configured"
+ )
+
+ def _open_slider_params_dialog(self, session, summary_label: QLabel) -> None:
+ """Open the slider-params table dialog and apply the result."""
+ if session.project_state is None:
+ QMessageBox.information(
+ None,
+ "Unavailable",
+ "Open this block's dialog from the diagram to list project variables.",
+ )
+ return
+
+ current = session.local_params.get("slider_params") or {}
+ dialog = SliderParamsDialog(session.project_state, current)
+ if dialog.exec() == QDialog.Accepted:
+ session.local_params["slider_params"] = dialog.slider_params()
+ self._refresh_slider_summary(session, summary_label)
diff --git a/pySimBlocks/gui/addons/sofa/sofa_dialog.py b/pySimBlocks/gui/addons/sofa/sofa_dialog.py
index b567a2b..5d758fd 100644
--- a/pySimBlocks/gui/addons/sofa/sofa_dialog.py
+++ b/pySimBlocks/gui/addons/sofa/sofa_dialog.py
@@ -107,12 +107,6 @@ def build_form(self, layout):
run_btn.clicked.connect(self.run)
form.addRow(label, run_btn)
- label = QLabel("Export Controller")
- label.setToolTip("Modify Sofa controller to run on cli.")
- export_btn = QPushButton("Export Controller")
- export_btn.clicked.connect(self.export)
- form.addRow(label, export_btn)
-
layout.addLayout(form)
def apply(self):
@@ -145,6 +139,8 @@ def run(self):
if not self._update_scene_file():
return
+ self.sofa_service.on_early_warning = self._show_early_warning
+
progress = QDialog(self)
progress.setWindowTitle("SOFA running")
progress.setModal(True)
@@ -163,7 +159,12 @@ def run(self):
ok, title, details = False, "Error launching SOFA", str(e)
finally:
progress.close()
- if not ok:
+
+ if ok:
+ self.sofa_service.project_state.logs = self.sofa_service.logs
+ if self.sofa_service.on_finished:
+ self.sofa_service.on_finished()
+ else :
dialog = LogDialog(
title=f"SOFA error – {title}",
content=details,
@@ -171,15 +172,6 @@ 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():
- return
- window = self.parent()
- self.sofa_service.export_controller(window, window.saver)
-
# --------------------------------------------------------------------------
# Private Methods
# --------------------------------------------------------------------------
@@ -200,6 +192,10 @@ def _update_scene_file(self):
)
return ok
+ def _show_early_warning(self, message: str):
+ """Show an immediate warning while SOFA is still running."""
+ QMessageBox.warning(self, "Project YAML mismatch", message)
+
class LogDialog(QDialog):
diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py
index da9fff9..da460fb 100644
--- a/pySimBlocks/gui/addons/sofa/sofa_service.py
+++ b/pySimBlocks/gui/addons/sofa/sofa_service.py
@@ -21,6 +21,9 @@
import os
import shutil
from pathlib import Path
+from typing import Callable
+
+import numpy as np
from PySide6.QtCore import QProcess, QProcessEnvironment
@@ -31,7 +34,19 @@
runtime_project_yaml_path,
save_yaml,
)
-from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller
+
+
+def sofa_logs_npz_path(project_dir: Path) -> Path:
+ """Path to the temporary logs dump written by the SOFA controller."""
+ return project_dir / ".sofa_logs.npz"
+
+
+def cleanup_sofa_logs_npz(project_dir: Path | None) -> None:
+ if project_dir is None:
+ return
+ npz = sofa_logs_npz_path(project_dir)
+ if npz.exists():
+ npz.unlink(missing_ok=True)
class SofaService:
@@ -61,6 +76,9 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr
self.sofa_path = ""
self.gui = "imgui"
self.scene_file = ""
+ self.on_early_warning = None
+ self.logs: dict = {}
+ self.on_finished: Callable | None = None
self._detect_sofa()
@@ -108,22 +126,6 @@ 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.
@@ -147,27 +149,26 @@ def run(self):
runtime_yaml = runtime_project_yaml_path(project_dir)
cleanup_runtime_project_yaml(project_dir)
save_yaml(project_state=self.project_state, runtime=True)
- try:
- generate_sofa_controller(project_yaml=runtime_yaml)
- except Exception as e:
- cleanup_runtime_project_yaml(project_dir)
- return False, "Could not update SOFA controller", str(e)
+ self._expected_yaml = runtime_yaml
+ self._project_yaml_checked = False
+ self.logs = {}
# set command
plugins = "SofaPython3"
if self.gui == "imgui":
plugins += ",SofaImgui"
- args = ["-l", plugins, "-g", self.gui, self.scene_file]
+ args = ["-l", plugins, "-g", self.gui, self.scene_file,
+ "--argv", f"--project-yaml,{runtime_yaml}"]
self._full_log = ""
self.process = QProcess()
env = QProcessEnvironment.systemEnvironment()
+ env.insert("PYSIMBLOCKS_SOFA_DUMP_LOGS", "1")
self.process.setProcessEnvironment(env)
self.process.setWorkingDirectory(str(Path(self.scene_file).parent))
self.process.setProgram(self.sofa_path)
self.process.setArguments(args)
-
self.process.setProcessChannelMode(QProcess.MergedChannels)
self.process.readyReadStandardOutput.connect(
lambda: self._accumulate_output()
@@ -179,23 +180,29 @@ def run(self):
return False, "Launch failed", "runSofa could not start"
self.process.waitForFinished(-1)
- try:
- generate_sofa_controller(project_dir)
- except Exception as e:
- return False, "Could not regenerate controller", "project.yaml does not exist.\n" + str(e)
-
# get output results
full_log = self._full_log
exit_code = self.process.exitCode()
if exit_code != 0:
return False, "SOFA exited with error", f"exit code = {exit_code}\n\n{full_log}"
+
pysimblocks_errors = [
line for line in full_log.splitlines()
if "[pySimBlocks] ERROR" in line
]
if pysimblocks_errors:
return False, "pySimBlocks configuration error", "\n".join(pysimblocks_errors)
+
+ warning = self._check_project_yaml_used(full_log, runtime_yaml)
+ if warning:
+ return False, "Project YAML mismatch", warning
+
+ load_status, msg = self._load_logs(project_dir)
+ if not load_status:
+ return False, "SOFA finished but logs not found", msg
+
return True, "SOFA finished", "Process terminated correctly"
+
finally:
cleanup_runtime_project_yaml(project_dir)
@@ -207,6 +214,9 @@ def _accumulate_output(self):
chunk = self.process.readAllStandardOutput().data().decode()
print(chunk, end="")
self._full_log += chunk
+
+ if not self._project_yaml_checked:
+ self._maybe_check_project_yaml_now()
def _check_sofa_environnment(self):
"""Validate the environment variables required to run SOFA."""
@@ -248,3 +258,76 @@ def _resolve_scene_file(self, scene_file: str) -> Path:
path = (project_dir / path).resolve()
return path
+
+ def _check_project_yaml_used(self, full_log: str, expected_yaml: Path) -> str | None:
+ """Check the log for a project_yaml mismatch or missing confirmation.
+
+ Args:
+ full_log: Accumulated stdout/stderr from the runSofa process.
+ expected_yaml: The runtime project.yaml path that was passed via
+ --argv for this run.
+
+ Returns:
+ A warning message if the controller's project_yaml doesn't match
+ or was never logged, otherwise None.
+ """
+ prefix = "[pySimBlocks] Controller using project_yaml: "
+ used_lines = [
+ line[len(prefix):].strip()
+ for line in full_log.splitlines()
+ if line.strip().startswith(prefix)
+ ]
+
+ if not used_lines:
+ return (
+ "The scene's controller did not report which project.yaml it used.\n"
+ "This usually means the scene's createScene() does not forward "
+ "--project-yaml to the controller (see the SOFA scaffold template)."
+ )
+
+ used_yaml = Path(used_lines[-1]).resolve()
+ if used_yaml != Path(expected_yaml).resolve():
+ return (
+ "The controller used a different project.yaml than expected:\n"
+ f" Expected: {expected_yaml}\n"
+ f" Used: {used_yaml}\n\n"
+ "Your GUI edits may not have been reflected in this run.\n"
+ "This usually means the scene's createScene() does not forward"
+ "--project-yaml to the controller (see the SOFA scaffold template).\n\n"
+ "You can create scene and controller template using `pysimblocks sofa-init`"
+ "and compare it to your scene's createScene() function."
+ )
+
+ return None
+
+ def _maybe_check_project_yaml_now(self):
+ """Check project_yaml as soon as the controller's confirmation line appears."""
+ prefix = "[pySimBlocks] Controller using project_yaml: "
+ if prefix not in self._full_log:
+ return
+
+ self._project_yaml_checked = True
+ warning = self._check_project_yaml_used(self._full_log, self._expected_yaml)
+ if warning and self.on_early_warning:
+ self.on_early_warning(warning)
+
+ def _load_logs(self, project_dir: Path) -> tuple[bool, str]:
+ """Load logs dumped by the SOFA controller, if present."""
+ npz_path = sofa_logs_npz_path(project_dir)
+ if not npz_path.exists():
+ self.logs = {}
+ return False, "No logs found"
+ try:
+ with np.load(npz_path, allow_pickle=True) as data:
+ logs = {}
+ for k in data.files:
+ arr = data[k]
+ if k == "time":
+ logs[k] = arr
+ else:
+ logs[k] = [arr[i] for i in range(arr.shape[0])]
+ self.logs = logs
+ return True, "Logs loaded"
+ except Exception as e:
+ self.logs = {}
+ return False, f"Failed to load logs: {e}"
diff --git a/pySimBlocks/gui/blocks/sources/file_source.py b/pySimBlocks/gui/blocks/sources/file_source.py
index 1c8583c..6802cf4 100644
--- a/pySimBlocks/gui/blocks/sources/file_source.py
+++ b/pySimBlocks/gui/blocks/sources/file_source.py
@@ -83,6 +83,17 @@ def __init__(self):
enum=[False, True],
description="If true (NPZ/CSV), use 'time' data and apply ZOH at simulation time t."
),
+ ParameterMeta(
+ name="transpose",
+ type="enum",
+ autofill=True,
+ default=False,
+ enum=[False, True],
+ description=(
+ "If true (NPZ/NPY only), transpose the loaded array before use, "
+ "i.e. treat data stored as (n, N) as (N, n)."
+ )
+ ),
ParameterMeta(
name="sample_time",
type="float",
@@ -123,6 +134,9 @@ def is_parameter_active(self,
if param_name == "use_time":
return ext != "npy"
+ if param_name == "transpose":
+ return ext != "csv"
+
return super().is_parameter_active(param_name, instance_params)
def build_param(
diff --git a/pySimBlocks/gui/blocks/systems/linear_state_space.py b/pySimBlocks/gui/blocks/systems/linear_state_space.py
index 047c62a..c909a46 100644
--- a/pySimBlocks/gui/blocks/systems/linear_state_space.py
+++ b/pySimBlocks/gui/blocks/systems/linear_state_space.py
@@ -45,8 +45,10 @@ def __init__(self):
"x[k+1] = A x[k] + B u[k]\n"
"$$\n"
"$$\n"
- "y[k] = C x[k]\n"
+ "y[k] = C x[k] + D u[k]\n"
"$$\n"
+ "When D is omitted the system is strictly proper (no direct feedthrough).\n"
+ "When D is provided the block has direct feedthrough.\n"
)
self.parameters = [
@@ -74,6 +76,12 @@ def __init__(self):
default=[[1.0]],
description="Output matrix."
),
+ ParameterMeta(
+ name="D",
+ type="matrix",
+ required=False,
+ description="Feedthrough matrix. If provided, y[k] = C x[k] + D u[k] (direct feedthrough)."
+ ),
ParameterMeta(
name="x0",
type="vector",
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 286dc87..c916872 100644
--- a/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py
+++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py
@@ -30,9 +30,10 @@
from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta
from pySimBlocks.gui.blocks.port_meta import PortMeta
from pySimBlocks.gui.models import BlockInstance, PortInstance
+from pySimBlocks.gui.addons.sofa.slider_params_dialog import SliderParamsRowMixin
-class SofaExchangeIOMeta(BlockMeta):
+class SofaExchangeIOMeta(SliderParamsRowMixin, BlockMeta):
"""Describe the GUI metadata of the SOFA exchange I/O block."""
def __init__(self):
@@ -181,6 +182,9 @@ def build_param(
file_filter="SOFA scene files (*.py);;All files (*)",
)
continue
+ if pmeta.name == "slider_params":
+ self._build_slider_params_row(session, form, pmeta, readonly=readonly)
+ continue
label, widget = self._create_param_row(session, pmeta, readonly)
if widget is None:
diff --git a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py
index 294ad60..9f2e660 100644
--- a/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py
+++ b/pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py
@@ -30,9 +30,10 @@
from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta
from pySimBlocks.gui.blocks.port_meta import PortMeta
from pySimBlocks.gui.models import BlockInstance, PortInstance
+from pySimBlocks.gui.addons.sofa.slider_params_dialog import SliderParamsRowMixin
-class SofaPlantMeta(BlockMeta):
+class SofaPlantMeta(SliderParamsRowMixin, BlockMeta):
"""Describe the GUI metadata of the SOFA plant block."""
def __init__(self):
@@ -180,6 +181,9 @@ def build_param(
file_filter="SOFA scene files (*.py);;All files (*)",
)
continue
+ if pmeta.name == "slider_params":
+ self._build_slider_params_row(session, form, pmeta, readonly=readonly)
+ continue
label, widget = self._create_param_row(session, pmeta, readonly)
if widget is None:
diff --git a/pySimBlocks/gui/dialogs/export_npz_dialog.py b/pySimBlocks/gui/dialogs/export_npz_dialog.py
new file mode 100644
index 0000000..5018e94
--- /dev/null
+++ b/pySimBlocks/gui/dialogs/export_npz_dialog.py
@@ -0,0 +1,170 @@
+# ******************************************************************************
+# pySimBlocks
+# Copyright (c) 2026 Université de Lille & INRIA
+# ******************************************************************************
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+# for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program. If not, see .
+# ******************************************************************************
+# Authors: see Authors.txt
+# ******************************************************************************
+
+from __future__ import annotations
+
+import numpy as np
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QHeaderView,
+ QLabel, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
+)
+
+from pySimBlocks.project.plot_series import stack_logged_signal
+
+
+class ExportNpzDialog(QDialog):
+ """Dialog letting the user pick which logged signals to export to .npz.
+
+ Displays one row per available signal, with a checkbox (checked by
+ default) controlling whether the signal is exported, and an editable
+ field for the key name to use in the resulting .npz archive.
+
+ Attributes:
+ logs: Mapping of signal name to the corresponding logged array.
+ table: Table widget holding one row per signal.
+ """
+
+ def __init__(self, logs: dict[str, np.ndarray],
+ key_names: dict[str, str],
+ decimation: int = 1,
+ parent=None):
+ """Initialize the export dialog and populate it from the logs.
+
+ Args:
+ logs: Mapping of signal name to the corresponding logged array.
+ parent: Optional parent widget.
+
+ Raises:
+ None.
+ """
+ super().__init__(parent)
+ self.setWindowTitle("Export to .npz")
+ self.resize(420, 400)
+
+ self.logs = logs
+
+ layout = QVBoxLayout(self)
+
+ # -------- Decimation --------
+ deci_row = QWidget()
+ deci_layout = QFormLayout(deci_row)
+ deci_layout.setContentsMargins(0, 0, 0, 8)
+ self.decimation_spin = QSpinBox()
+ self.decimation_spin.setMinimum(1)
+ self.decimation_spin.setMaximum(1_000_000)
+ self.decimation_spin.setValue(max(1, decimation))
+ self.decimation_spin.setToolTip("Keep 1 sample every N (1 = no decimation)")
+ deci_layout.addRow("Decimation (keep 1 in N):", self.decimation_spin)
+ layout.addWidget(deci_row)
+
+ self.table = QTableWidget(len(logs), 3, self)
+ self.table.setHorizontalHeaderLabels(["Export", "Variable", "Key name"])
+ self.table.verticalHeader().setVisible(False)
+ self.table.setSelectionMode(QAbstractItemView.NoSelection)
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
+
+ for row, key in enumerate(self.logs.keys()):
+ check_item = QTableWidgetItem()
+ check_item.setFlags(
+ (check_item.flags() | Qt.ItemIsUserCheckable) & ~Qt.ItemIsEditable
+ )
+ check_item.setCheckState(Qt.Checked)
+ self.table.setItem(row, 0, check_item)
+
+ var_item = QTableWidgetItem(key)
+ var_item.setFlags(var_item.flags() & ~Qt.ItemIsEditable)
+ self.table.setItem(row, 1, var_item)
+
+ default_name = key_names.get(key, key)
+ name_item = QTableWidgetItem(default_name)
+ self.table.setItem(row, 2, name_item)
+
+ layout.addWidget(self.table)
+
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ # --------------------------------------------------------------------------
+ # Public Methods
+ # --------------------------------------------------------------------------
+
+ def decimation_value(self) -> int:
+ """Return the decimation factor entered by the user."""
+ return self.decimation_spin.value()
+
+ def selected_arrays(self) -> dict[str, np.ndarray]:
+ """Return the arrays selected for export, keyed by their edited name.
+
+ Each signal is stacked over time (``logs`` stores raw per-step
+ samples as lists, pySimBlocks' internal representation) and a
+ trailing singleton axis is dropped, since every signal is logged
+ internally as a 2D column vector even when it is a scalar.
+
+ Returns:
+ Mapping of (possibly renamed) key to the ready-to-save array,
+ containing only the rows whose checkbox is checked. If two rows
+ end up with the same key name, the last one wins.
+ """
+ result: dict[str, np.ndarray] = {}
+ for row, original_key in enumerate(self.logs.keys()):
+ check_item = self.table.item(row, 0)
+ if check_item.checkState() != Qt.Checked:
+ continue
+ name_item = self.table.item(row, 2)
+ key = name_item.text().strip() or original_key
+ result[key] = self._stack(original_key)
+ return result
+
+ def key_mapping(self) -> dict[str, str]:
+ """Return the current variable -> key-name mapping (all rows, even unchecked)."""
+ mapping = {}
+ for row, original_key in enumerate(self.logs.keys()):
+ name_item = self.table.item(row, 2)
+ mapping[original_key] = name_item.text().strip() or original_key
+ return mapping
+
+ # --------------------------------------------------------------------------
+ # Private Methods
+ # --------------------------------------------------------------------------
+
+ def _stack(self, original_key: str) -> np.ndarray:
+ """Stack the raw per-step samples of a signal and drop a trailing
+ singleton axis.
+
+ Args:
+ original_key: Log key as stored in ``self.logs`` (unrenamed).
+
+ Returns:
+ Stacked array, ``(T,)`` for ``time`` and ``(T, *sample_shape)``
+ (trailing size-1 axis squeezed) for every other signal.
+ """
+ n = self.decimation_spin.value()
+ if original_key == "time":
+ arr = np.asarray(self.logs[original_key]).flatten()
+ return arr[::n]
+
+ arr = stack_logged_signal(self.logs, original_key)
+ if arr.ndim >= 2 and arr.shape[-1] == 1:
+ arr = arr.squeeze(-1)
+ return arr[::n]
diff --git a/pySimBlocks/gui/dialogs/settings/project.py b/pySimBlocks/gui/dialogs/settings/project.py
index ea6a6fd..f179ecc 100644
--- a/pySimBlocks/gui/dialogs/settings/project.py
+++ b/pySimBlocks/gui/dialogs/settings/project.py
@@ -90,6 +90,18 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr
label.setToolTip("Relative path from project directory")
layout.addRow(label, external_layout)
+ export_path = project_state.npz_export_path or ""
+ self.npz_export_path_edit = QLineEdit(export_path)
+ self.npz_export_path_browse_btn = QPushButton("...")
+ self.npz_export_path_browse_btn.setToolTip("Select default .npz export path")
+ self.npz_export_path_browse_btn.clicked.connect(self.browse_npz_export_path)
+
+ npz_layout = QHBoxLayout()
+ npz_layout.setContentsMargins(0, 0, 0, 0)
+ npz_layout.addWidget(self.npz_export_path_edit)
+ npz_layout.addWidget(self.npz_export_path_browse_btn)
+
+ layout.addRow("Default .npz export path:", npz_layout)
# --------------------------------------------------------------------------
@@ -112,6 +124,7 @@ def apply(self) -> bool:
return False
ext = self.external_edit.text().strip()
self.project_controller.update_project_param(path, ext)
+ self.project_controller.update_npz_export_path(self.npz_export_path_edit.text().strip())
return True
def browse_external_file(self):
@@ -174,3 +187,13 @@ def load_project(self):
ext = self.project_state.external
self.external_edit.setText("" if ext is None else ext)
self.settings_dialog.refresh_tabs_from_project()
+
+ def browse_npz_export_path(self):
+ """Select the default .npz export file path from the filesystem."""
+ current = Path(self.npz_export_path_edit.text()).expanduser()
+ start = str(current) if current.parent.is_dir() else str(Path.cwd())
+ selected_path, _ = QFileDialog.getSaveFileName(
+ self, "Select default .npz export path", start, "NumPy archive (*.npz)"
+ )
+ if selected_path:
+ self.npz_export_path_edit.setText(str(Path(selected_path).resolve()))
diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py
index aeee02e..958b8cd 100644
--- a/pySimBlocks/gui/group_boundary_labels.py
+++ b/pySimBlocks/gui/group_boundary_labels.py
@@ -33,24 +33,6 @@ def boundary_port_label(
group: VisualGroup,
boundary: BoundaryPort,
) -> str:
- """Return the label shown on a group border port."""
- if boundary.origin == "manual":
- return manual_boundary_display_label(boundary)
if boundary.label.strip():
return boundary.label.strip()
-
- internal = (
- find_port(state, boundary.linked_port_uid)
- if boundary.linked_port_uid
- else None
- )
- if internal is None:
- return ""
-
- connection = find_connection_for_boundary(state, boundary)
- if connection is None:
- return _port_display(internal)
-
- if boundary.direction == "input":
- return _port_display(connection.src_port)
- return _port_display(connection.dst_port)
+ return proxy_default_label(boundary)
diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py
index 1c4c598..261151f 100644
--- a/pySimBlocks/gui/models/project_state.py
+++ b/pySimBlocks/gui/models/project_state.py
@@ -57,7 +57,9 @@ def __init__(self, directory_path: Path):
self.logs: dict = {}
self.plots: list[dict[str, str | list[str]]] = []
self.visual_groups: list[VisualGroup] = []
-
+ self.npz_export_path: str | None = None
+ self.npz_key_names: dict[str, str] = {}
+ self.npz_decimation: int = 1
# --- Public methods ---
@@ -74,18 +76,33 @@ def clear(self):
self.simulation.clear()
self.external = None
-
- def load_simulation(self, sim_data: dict, external = None):
+ self.npz_export_path = None
+ self.npz_key_names = {}
+ self.npz_decimation = 1
+
+ def load_simulation(self, sim_data: dict, external = None,
+ npz_export_path: str | None = None,
+ npz_key_names: dict[str, str] | None = None,
+ npz_decimation: int | None = None
+ ) -> None:
"""Load simulation settings into the project state.
Args:
sim_data: Serialized simulation settings.
external: Optional external runtime value.
+ npz_export_path: Optional path for NPZ export.
+ npz_key_names: Optional mapping of signal names to NPZ keys.
"""
self.simulation.load_from_dict(sim_data)
if external:
self.external = external
+ if npz_export_path:
+ self.npz_export_path = npz_export_path
+ if npz_key_names:
+ self.npz_key_names = npz_key_names
+ if npz_decimation is not None:
+ self.npz_decimation = npz_decimation
def get_block(self, name:str):
"""Return the block with the given name if it exists.
diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py
index 220a0e0..477d36e 100644
--- a/pySimBlocks/gui/project_controller.py
+++ b/pySimBlocks/gui/project_controller.py
@@ -1155,6 +1155,37 @@ def load_project(self, loader: "ProjectLoader") -> None:
self.apply_member_layouts(group)
self.view.refresh_visual_groups()
+ def update_npz_export_path(self, path: str) -> None:
+ """Update the last/default .npz export path.
+
+ Args:
+ path: New export file path, or '' to clear it.
+ """
+ new_value = None if path == "" else path
+ if new_value == self.project_state.npz_export_path:
+ return
+ self.project_state.npz_export_path = new_value
+ self.make_dirty()
+
+ def update_npz_key_names(self, mapping: dict[str, str]) -> None:
+ """Persist the variable → export-key renaming for next time."""
+ cleaned = {k: v for k, v in mapping.items() if v and v != k}
+ if cleaned == self.project_state.npz_key_names:
+ return
+ self.project_state.npz_key_names = cleaned
+ self.make_dirty()
+
+ def update_npz_decimation(self, value: int) -> None:
+ """Update the default decimation factor for .npz exports.
+
+ Args:
+ value: Keep 1 sample every `value` (>=1, 1 = no decimation).
+ """
+ new_value = max(1, int(value))
+ if new_value == self.project_state.npz_decimation:
+ return
+ self.project_state.npz_decimation = new_value
+ self.make_dirty()
# --------------------------------------------------------------------------
# Plot methods
@@ -2034,6 +2065,12 @@ def ensure_group_boundary_proxies(self, group: VisualGroup) -> None:
"""Assign proxy ids and default layouts for each group boundary port."""
inputs = [port for port in group.boundary_ports if port.direction == "input"]
outputs = [port for port in group.boundary_ports if port.direction == "output"]
+
+ # Assign unique labels to auto ports without custom labels
+ for port in group.boundary_ports:
+ if not port.label.strip() and port.origin == "auto":
+ port.label = self._make_unique_boundary_label(group, port.direction)
+
for index, port in enumerate(inputs):
self._ensure_boundary_proxy(port, "input", index, len(inputs), group)
for index, port in enumerate(outputs):
@@ -2205,7 +2242,7 @@ def _make_unique_boundary_label(
base = self._proxy_default_label(direction)
used: set[str] = set()
for port in group.boundary_ports:
- if port.uid == exclude_uid or port.origin != "manual":
+ if port.uid == exclude_uid:
continue
if port.direction != direction:
continue
@@ -2427,6 +2464,7 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None:
port.proxy_uid = previous.proxy_uid
port.proxy_layout = dict(previous.proxy_layout)
port.external_port_uid = previous.external_port_uid
+ port.label = previous.label
if previous.linked_port_uid:
port.linked_port_uid = previous.linked_port_uid
if not port.linked_connection_uid:
diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py
index a81c156..85bebbe 100644
--- a/pySimBlocks/gui/services/project_loader.py
+++ b/pySimBlocks/gui/services/project_loader.py
@@ -104,7 +104,11 @@ def _load_simulation(self, controller: ProjectController, sim_data: dict):
if not isinstance(sim_data, dict):
sim_data = {}
controller.project_state.load_simulation(
- sim_data, sim_data.get("external_module", None)
+ sim_data,
+ sim_data.get("external_module", None),
+ sim_data.get("npz_export_path", None),
+ sim_data.get("npz_key_names", None),
+ sim_data.get("npz_decimation", None)
)
def _load_blocks(
diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py
index d15991f..4e3ecba 100644
--- a/pySimBlocks/gui/services/yaml_tools.py
+++ b/pySimBlocks/gui/services/yaml_tools.py
@@ -183,6 +183,12 @@ def _build_simulation_section(project_state: ProjectState) -> dict:
if project_state.external is not None:
simulation["external_module"] = project_state.external
+ if project_state.npz_export_path is not None:
+ simulation["npz_export_path"] = project_state.npz_export_path
+ if project_state.npz_key_names:
+ simulation["npz_key_names"] = dict(project_state.npz_key_names)
+ if project_state.npz_decimation:
+ simulation["npz_decimation"] = project_state.npz_decimation
simulation["logging"] = list(project_state.logging)
simulation["plots"] = list(project_state.plots)
diff --git a/pySimBlocks/gui/widgets/toolbar_view.py b/pySimBlocks/gui/widgets/toolbar_view.py
index ec8998d..5a92b43 100644
--- a/pySimBlocks/gui/widgets/toolbar_view.py
+++ b/pySimBlocks/gui/widgets/toolbar_view.py
@@ -20,12 +20,18 @@
from __future__ import annotations
-from PySide6.QtWidgets import QToolBar, QMessageBox, QProgressDialog, QApplication, QToolButton
+import numpy as np
+
+from PySide6.QtWidgets import (
+ QToolBar, QMessageBox, QProgressDialog, QApplication, QToolButton,
+ QFileDialog, QDialog
+)
from PySide6.QtGui import QAction
from PySide6.QtCore import Qt
from shiboken6 import isValid
from pySimBlocks.gui.dialogs.display_yaml_dialog import DisplayYamlDialog
+from pySimBlocks.gui.dialogs.export_npz_dialog import ExportNpzDialog
from pySimBlocks.gui.dialogs.settings_dialog import SettingsDialog
from pySimBlocks.gui.project_controller import ProjectController
from pySimBlocks.gui.services.project_saver import ProjectSaver
@@ -101,7 +107,7 @@ def __init__(
self.addSeparator()
- export_action = QAction("Export", self)
+ export_action = QAction("Export .py", self)
export_action.triggered.connect(self.on_export_project)
self.addAction(export_action)
@@ -121,11 +127,17 @@ def __init__(
plot_action.triggered.connect(self.on_plot_logs)
self.addAction(plot_action)
+
+ export_npz_action = QAction("Export .npz", self)
+ export_npz_action.triggered.connect(self.on_export_npz)
+ self.addAction(export_npz_action)
+
# add ons
self.sofa_service = SofaService(self.project_controller.project_state, self.project_controller)
self.sofa_action = QAction("Sofa", self)
self.sofa_action.triggered.connect(self.on_open_sofa_dialog)
self.addAction(self.sofa_action)
+ self.sofa_service.on_finished = self._refresh_plot_after_sofa
# --------------------------------------------------------------------------
@@ -222,6 +234,37 @@ def on_plot_logs(self) -> None:
dlg.present()
+ def on_export_npz(self) -> None:
+ """Export selected logged signals to a .npz file chosen by the user."""
+ logs = self.project_controller.project_state.logs
+ if not logs:
+ QMessageBox.warning(self, "Export .npz", "No simulation logs available.")
+ return
+ dlg = ExportNpzDialog(
+ logs,
+ self.project_controller.project_state.npz_key_names,
+ self.project_controller.project_state.npz_decimation,
+ self)
+ if dlg.exec() != QDialog.Accepted:
+ return
+ to_save = dlg.selected_arrays()
+ if not to_save:
+ return
+
+ start_path = self.project_controller.project_state.npz_export_path or "."
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save .npz", start_path, "NumPy archive (*.npz)"
+ )
+ if not path:
+ return
+ if not path.lower().endswith(".npz"):
+ path += ".npz"
+
+ np.savez(path, **to_save)
+ self.project_controller.update_npz_export_path(path)
+ self.project_controller.update_npz_key_names(dlg.key_mapping())
+ self.project_controller.update_npz_decimation(dlg.decimation_value())
+
def set_running(self, running: bool) -> None:
"""Enable or disable all toolbar actions based on the running state.
@@ -240,10 +283,6 @@ def refresh_sofa_button(self) -> None:
if self.sofa_action in self.actions():
self.removeAction(self.sofa_action)
- def _focus_view_after_history_action(self) -> None:
- """Return keyboard focus to the canvas after undo/redo from toolbar."""
- self.project_controller.view.setFocus()
-
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()
@@ -257,3 +296,16 @@ def on_open_sofa_dialog(self) -> None:
return
dialog = SofaDialog(self.sofa_service, self.parent())
dialog.exec()
+
+ # --------------------------------------------------------------------------
+ # Private Methods
+ # --------------------------------------------------------------------------
+
+ def _focus_view_after_history_action(self) -> None:
+ """Return keyboard focus to the canvas after undo/redo from toolbar."""
+ self.project_controller.view.setFocus()
+
+ def _refresh_plot_after_sofa(self):
+ dlg = self._plot_dialog
+ if dlg is not None and isValid(dlg):
+ dlg.present()
diff --git a/pySimBlocks/project/generate_sofa_controller.py b/pySimBlocks/project/generate_sofa_controller.py
deleted file mode 100644
index 4839e01..0000000
--- a/pySimBlocks/project/generate_sofa_controller.py
+++ /dev/null
@@ -1,259 +0,0 @@
-# ******************************************************************************
-# pySimBlocks
-# Copyright (c) 2026 Université de Lille & INRIA
-# ******************************************************************************
-# This program is free software: you can redistribute it and/or modify it
-# under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or (at your
-# option) any later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
-# for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program. If not, see .
-# ******************************************************************************
-# Authors: see Authors.txt
-# ******************************************************************************
-
-from __future__ import annotations
-
-import importlib.util
-import inspect
-import os
-import re
-import sys
-from multiprocessing import Pipe, Process
-from pathlib import Path
-
-import yaml
-
-
-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
- if str(scene_dir) not in sys.path:
- sys.path.insert(0, str(scene_dir))
-
- spec = importlib.util.spec_from_file_location("scene", scene_path)
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
-
- import Sofa
- root = Sofa.Core.Node("root")
-
- out = mod.createScene(root)
- if not isinstance(out, (list, tuple)) or len(out) < 2:
- conn.send(None)
- return
-
- controller = out[1]
- controller_file = inspect.getsourcefile(controller.__class__)
- conn.send(controller_file)
-
- except Exception as e:
- print(f"Error {e}")
- conn.send(None)
-
- finally:
- conn.close()
-
-
-def detect_controller_file_from_scene(scene_file: Path) -> Path:
- """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))
- p.start()
- try:
- controller_path = parent_conn.recv()
- except EOFError:
- controller_path = None
- p.join()
-
- if controller_path is None:
- raise RuntimeError(
- f"Unable to determine controller file from scene {scene_file}. "
- "Ensure createScene(root) returns (root, controller)."
- )
- return Path(controller_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
-
- injection = (
- "from pathlib import Path\n\n"
- "BASE_DIR = Path(__file__).resolve().parent\n\n"
- )
-
- import_block = list(re.finditer(r"^(import|from)\s+.+$", src, re.MULTILINE))
- if import_block:
- last = import_block[-1]
- insert_at = last.end()
- return src[:insert_at] + "\n\n" + injection + src[insert_at:]
-
- return injection + src
-
-
-def inject_project_path_into_controller(
- controller_file: Path,
- project_yaml: Path,
-) -> None:
- """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)
-
- controller_dir = controller_file.parent
- project_yaml = project_yaml.resolve()
-
- try:
- rel_project = Path(os.path.relpath(project_yaml, controller_dir))
- project_expr = f"(BASE_DIR / {rel_project.as_posix()!r}).resolve()"
- except ValueError:
- project_expr = f"Path({project_yaml.as_posix()!r}).resolve()"
-
- expr = f"self.project_yaml = str({project_expr})"
-
- pattern = r"self\.project_yaml\s*=.*"
- if re.search(pattern, src):
- src = re.sub(pattern, expr, src)
- else:
- src = src.replace(
- "super().__init__(name=name)",
- f"super().__init__(name=name)\n {expr}",
- )
-
- controller_file.write_text(src)
-
-
-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}")
-
- raw = yaml.safe_load(project_yaml.read_text()) or {}
- if not isinstance(raw, dict):
- raise ValueError("project.yaml must define a YAML mapping")
- return raw
-
-
-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")
-
- blocks = diagram.get("blocks", [])
- if not isinstance(blocks, list):
- raise ValueError("'diagram.blocks' section must be a list")
-
- sofa_block = next(
- (
- b
- for b in blocks
- if isinstance(b, dict)
- and str(b.get("type", "")).lower() in ("sofa_plant", "sofa_exchange_i_o")
- ),
- None,
- )
- if sofa_block is None:
- raise RuntimeError(
- "No SofaPlant or SofaExchangeIO block found in project.yaml"
- )
- return sofa_block
-
-
-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(
- f"'diagram.blocks[{sofa_block.get('name', '?')}].parameters' must be a mapping"
- )
-
- scene_file = params.get("scene_file", None)
- if not isinstance(scene_file, str) or not scene_file:
- raise KeyError(
- f"'scene_file' must be defined in parameters for block '{sofa_block.get('name', '?')}'"
- )
-
- path = Path(scene_file).expanduser()
- if not path.is_absolute():
- path = (project_yaml.parent / path).resolve()
- return path
-
-
-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:
- raise ValueError("Cannot use project_dir together with project_yaml.")
-
- if not project_dir and not has_project_path:
- raise ValueError("You must specify either project_dir or project_yaml.")
-
- if project_dir:
- project_yaml = Path(project_dir).resolve() / "project.yaml"
- else:
- project_yaml = Path(project_yaml).resolve()
-
- raw_project = _load_project_yaml(project_yaml)
- sofa_block = _find_sofa_block(raw_project)
- scene_file = _resolve_scene_file(project_yaml, sofa_block)
- controller_file = detect_controller_file_from_scene(scene_file)
-
- inject_project_path_into_controller(controller_file, project_yaml)
- print(f"[pySimBlocks] SOFA controller updated: {controller_file}")
diff --git a/pySimBlocks/project/generate_sofa_scaffold.py b/pySimBlocks/project/generate_sofa_scaffold.py
new file mode 100644
index 0000000..585b071
--- /dev/null
+++ b/pySimBlocks/project/generate_sofa_scaffold.py
@@ -0,0 +1,132 @@
+# ******************************************************************************
+# pySimBlocks
+# Copyright (c) 2026 Université de Lille & INRIA
+# ******************************************************************************
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+# ******************************************************************************
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+SCENE_TEMPLATE = '''\
+"""SOFA scene template generated by `pysimblocks sofa-init`.
+
+Run standalone:
+ runSofa {name}_scene.py --argv --project-yaml,path/to/project.yaml
+
+Run via pySimBlocks (GUI or CLI): the --project-yaml value is injected
+automatically through --argv, no manual flag needed.
+"""
+import sys, argparse
+
+
+def createScene(rootnode):
+ parser = argparse.ArgumentParser(prog=sys.argv[0])
+ parser.add_argument("--project-yaml", type=str, default=None, dest="project_yaml")
+ args, _ = parser.parse_known_args()
+
+ # TODO: build your SOFA scene graph here
+
+ # Instantiate your controller and add it to the scene graph
+ from {name}_controller import {class_name}Controller
+ controller = {class_name}Controller(
+ rootnode,
+ # TODO: add your own constructor arguments here
+ project_yaml=args.project_yaml,
+ )
+ rootnode.addObject(controller)
+
+ return rootnode, controller
+'''
+
+CONTROLLER_TEMPLATE = '''\
+"""SOFA controller template generated by `pysimblocks sofa-init`.
+"""
+from pathlib import Path
+
+from pySimBlocks.blocks.systems.sofa import SofaPysimBlocksController
+
+BASE_DIR = Path(__file__).resolve().parent
+
+
+class {class_name}Controller(SofaPysimBlocksController):
+
+ def __init__(self, root, project_yaml=None, name="{class_name}Controller"):
+ project_yaml = project_yaml or str((BASE_DIR / "project.yaml").resolve())
+
+ # You can modify the project_yaml in case it is None, e.g no argument
+ # is passed => default to a project.yaml
+ super().__init__(project_yaml=project_yaml, name=name)
+
+ self.root = root
+ self.inputs = {{}} # TODO: e.g. {{"u": np.zeros((n, 1))}}
+ self.outputs = {{}} # TODO: e.g. {{"y": np.zeros((m, 1))}}
+
+ def set_inputs(self):
+ raise NotImplementedError("TODO: apply self.inputs to your SOFA scene")
+
+ def get_outputs(self):
+ raise NotImplementedError("TODO: read your SOFA scene into self.outputs")
+'''
+
+
+def _to_class_name(name: str) -> str:
+ """Convert a snake_case or kebab-case name to PascalCase."""
+ parts = name.replace("-", "_").split("_")
+ return "".join(p.capitalize() for p in parts if p)
+
+
+def generate_sofa_scaffold(
+ name: str,
+ output_dir: Path | str | None = None,
+ force: bool = False,
+) -> tuple[Path, Path]:
+ """Generate a starter SOFA scene + controller pair.
+
+ Writes ``_scene.py`` and ``_controller.py`` pre-filled with
+ the ``--project-yaml`` argument parsing and the correct
+ ``super().__init__()`` ordering, so new SOFA controllers work correctly
+ both standalone (``runSofa``) and when driven by pySimBlocks.
+
+ Args:
+ name: Base name used for the generated files and the controller
+ class (e.g. ``"finger"`` -> ``finger_scene.py``,
+ ``finger_controller.py``, class ``FingerController``).
+ output_dir: Directory to write the files into. Defaults to the
+ current working directory.
+ force: If True, overwrite existing files. Otherwise raises if either
+ target file already exists.
+
+ Returns:
+ Tuple of (scene_path, controller_path) for the generated files.
+
+ Raises:
+ FileExistsError: If a target file already exists and force is False.
+ """
+ output_dir = Path(output_dir).resolve() if output_dir else Path.cwd()
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ class_name = _to_class_name(name)
+ scene_path = output_dir / f"{name}_scene.py"
+ controller_path = output_dir / f"{name}_controller.py"
+
+ if not force:
+ existing = [p for p in (scene_path, controller_path) if p.exists()]
+ if existing:
+ raise FileExistsError(
+ f"File(s) already exist: {', '.join(str(p) for p in existing)}. "
+ "Use force=True to overwrite."
+ )
+
+ scene_path.write_text(SCENE_TEMPLATE.format(name=name, class_name=class_name))
+ controller_path.write_text(CONTROLLER_TEMPLATE.format(class_name=class_name))
+
+ print(f"[pySimBlocks] Generated {scene_path}")
+ print(f"[pySimBlocks] Generated {controller_path}")
+
+ return scene_path, controller_path
diff --git a/tests/gui/test_group_boundary_labels.py b/tests/gui/test_group_boundary_labels.py
index 1e4a27a..55ed9f5 100644
--- a/tests/gui/test_group_boundary_labels.py
+++ b/tests/gui/test_group_boundary_labels.py
@@ -31,41 +31,6 @@ def _create_window(qtbot, tmp_path):
qtbot.waitUntil(lambda: window.isVisible())
return window
-
-def test_boundary_port_label_input_shows_external_source(qtbot, tmp_path):
- window = _create_window(qtbot, tmp_path)
- controller = window.project_controller
-
- src = controller.add_block("sources", "constant")
- _first_port(src, "output").display_as = "u"
- gain = controller.add_block("operators", "gain")
- out = controller.add_block("operators", "sum")
- controller.add_connection(_first_port(src, "output"), _first_port(gain, "input"))
- controller.add_connection(_first_port(gain, "output"), _first_port(out, "input"))
-
- group = controller.group_blocks([gain, out], name="G")
- boundary = next(p for p in group.boundary_ports if p.direction == "input")
-
- assert boundary_port_label(controller.project_state, group, boundary) == "u"
-
-
-def test_boundary_port_label_output_shows_external_destination(qtbot, tmp_path):
- window = _create_window(qtbot, tmp_path)
- controller = window.project_controller
-
- src = controller.add_block("sources", "constant")
- gain = controller.add_block("operators", "gain")
- sink = controller.add_block("operators", "sum")
- _port_named(sink, "in1").display_as = "y"
- controller.add_connection(_first_port(src, "output"), _first_port(gain, "input"))
- controller.add_connection(_first_port(gain, "output"), _port_named(sink, "in1"))
-
- group = controller.group_blocks([src, gain], name="G")
- boundary = next(p for p in group.boundary_ports if p.direction == "output")
-
- assert boundary_port_label(controller.project_state, group, boundary) == "y"
-
-
def test_boundary_port_label_manual_defaults_to_in_or_out():
boundary = BoundaryPort(uid="b1", direction="input", origin="manual")
group = VisualGroup(uid="g1", name="G", members=["m1"], boundary_ports=[boundary])