From 375538214fea72608d164a9eb749c6877739abe2 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 28 Jul 2026 17:50:19 +0200 Subject: [PATCH 1/9] feat: add linear sys feedforward --- .../blocks/systems/linear_state_space.py | 45 ++++++++++++++++--- .../docs/blocks/systems/linear_state_space.md | 27 ++++++++--- .../gui/blocks/systems/linear_state_space.py | 10 ++++- 3 files changed, 71 insertions(+), 11 deletions(-) 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/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/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", From e475fd0a7160f7980d3a64d42360450db72e2cbb Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Thu, 30 Jul 2026 12:20:54 +0200 Subject: [PATCH 2/9] feat: file source transpode data --- pySimBlocks/blocks/sources/file_source.py | 35 ++++++++++++++++--- .../docs/blocks/sources/file_source.md | 5 ++- pySimBlocks/gui/blocks/sources/file_source.py | 14 ++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) 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/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/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( From 76c5867a17f60a19bcc7e23b9dc6871cc89a9bd6 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 31 Jul 2026 13:16:44 +0200 Subject: [PATCH 3/9] fix: remove auto group port naming --- pySimBlocks/gui/group_boundary_labels.py | 20 +------------------- pySimBlocks/gui/project_controller.py | 9 ++++++++- 2 files changed, 9 insertions(+), 20 deletions(-) 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/project_controller.py b/pySimBlocks/gui/project_controller.py index 220a0e0..e76372f 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -2034,6 +2034,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 +2211,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 +2433,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: From 10f2438553d3ea74895fbf23b5a1e2e2c01f649a Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Fri, 31 Jul 2026 13:31:14 +0200 Subject: [PATCH 4/9] fix: remove useless test for group port auto naming --- tests/gui/test_group_boundary_labels.py | 35 ------------------------- 1 file changed, 35 deletions(-) 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]) From cf6ea49c895248ddd8064a1cbe1f5f07891286c3 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 4 Aug 2026 14:28:53 +0200 Subject: [PATCH 5/9] fix: remove sofa export, give yaml path as command line argument and add sofa template command --- .../blocks/systems/sofa/sofa_controller.py | 6 +- pySimBlocks/cli.py | 46 ++-- pySimBlocks/gui/addons/sofa/sofa_dialog.py | 21 +- pySimBlocks/gui/addons/sofa/sofa_service.py | 96 +++++-- .../project/generate_sofa_controller.py | 259 ------------------ pySimBlocks/project/generate_sofa_scaffold.py | 132 +++++++++ 6 files changed, 238 insertions(+), 322 deletions(-) delete mode 100644 pySimBlocks/project/generate_sofa_controller.py create mode 100644 pySimBlocks/project/generate_sofa_scaffold.py diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py index eb2b704..f15e9ad 100644 --- a/pySimBlocks/blocks/systems/sofa/sofa_controller.py +++ b/pySimBlocks/blocks/systems/sofa/sofa_controller.py @@ -69,10 +69,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 +92,10 @@ 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}") # -------------------------------------------------------------------------- # Public methods 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/gui/addons/sofa/sofa_dialog.py b/pySimBlocks/gui/addons/sofa/sofa_dialog.py index b567a2b..8ea4ca8 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) @@ -171,15 +167,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 +187,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..edcbfdb 100644 --- a/pySimBlocks/gui/addons/sofa/sofa_service.py +++ b/pySimBlocks/gui/addons/sofa/sofa_service.py @@ -31,7 +31,6 @@ runtime_project_yaml_path, save_yaml, ) -from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller class SofaService: @@ -61,6 +60,7 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr self.sofa_path = "" self.gui = "imgui" self.scene_file = "" + self.on_early_warning = None self._detect_sofa() @@ -108,22 +108,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,17 +131,15 @@ 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 # 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 = "" @@ -167,7 +149,6 @@ def run(self): 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 +160,25 @@ 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 + return True, "SOFA finished", "Process terminated correctly" + finally: cleanup_runtime_project_yaml(project_dir) @@ -207,6 +190,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 +234,55 @@ 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) 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 From 79856ca4666aeb4cdfb944a99abcd8e52863ba33 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 4 Aug 2026 15:29:48 +0200 Subject: [PATCH 6/9] feat: sofa master acces data post simulation --- .../blocks/systems/sofa/sofa_controller.py | 24 ++++++++++ pySimBlocks/gui/addons/sofa/sofa_dialog.py | 7 ++- pySimBlocks/gui/addons/sofa/sofa_service.py | 45 +++++++++++++++++++ pySimBlocks/gui/widgets/toolbar_view.py | 18 ++++++-- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/pySimBlocks/blocks/systems/sofa/sofa_controller.py b/pySimBlocks/blocks/systems/sofa/sofa_controller.py index f15e9ad..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 @@ -97,6 +99,12 @@ def __init__(self, project_yaml: str, name: str = "SofaControllerGui"): 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 # -------------------------------------------------------------------------- @@ -429,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/gui/addons/sofa/sofa_dialog.py b/pySimBlocks/gui/addons/sofa/sofa_dialog.py index 8ea4ca8..5d758fd 100644 --- a/pySimBlocks/gui/addons/sofa/sofa_dialog.py +++ b/pySimBlocks/gui/addons/sofa/sofa_dialog.py @@ -159,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, diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py index edcbfdb..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 @@ -33,6 +36,19 @@ ) +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: """Manage SOFA-specific validation, export, and execution workflows. @@ -61,6 +77,8 @@ def __init__(self, project_state: ProjectState, project_controller: ProjectContr self.gui = "imgui" self.scene_file = "" self.on_early_warning = None + self.logs: dict = {} + self.on_finished: Callable | None = None self._detect_sofa() @@ -133,6 +151,7 @@ def run(self): save_yaml(project_state=self.project_state, runtime=True) self._expected_yaml = runtime_yaml self._project_yaml_checked = False + self.logs = {} # set command plugins = "SofaPython3" @@ -145,6 +164,7 @@ def run(self): 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) @@ -177,6 +197,10 @@ def run(self): 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: @@ -286,3 +310,24 @@ def _maybe_check_project_yaml_now(self): 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/widgets/toolbar_view.py b/pySimBlocks/gui/widgets/toolbar_view.py index ec8998d..45c8722 100644 --- a/pySimBlocks/gui/widgets/toolbar_view.py +++ b/pySimBlocks/gui/widgets/toolbar_view.py @@ -126,6 +126,7 @@ def __init__( 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 # -------------------------------------------------------------------------- @@ -240,10 +241,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 +254,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() From 18793e15abc3889cdadb435f6e3ecc163fa4c231 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 4 Aug 2026 16:54:38 +0200 Subject: [PATCH 7/9] feat: export data on npz --- pySimBlocks/gui/dialogs/export_npz_dialog.py | 155 +++++++++++++++++++ pySimBlocks/gui/dialogs/settings/project.py | 23 +++ pySimBlocks/gui/models/project_state.py | 16 +- pySimBlocks/gui/project_controller.py | 19 +++ pySimBlocks/gui/services/project_loader.py | 5 +- pySimBlocks/gui/services/yaml_tools.py | 4 + pySimBlocks/gui/widgets/toolbar_view.py | 41 ++++- 7 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 pySimBlocks/gui/dialogs/export_npz_dialog.py diff --git a/pySimBlocks/gui/dialogs/export_npz_dialog.py b/pySimBlocks/gui/dialogs/export_npz_dialog.py new file mode 100644 index 0000000..3b442c4 --- /dev/null +++ b/pySimBlocks/gui/dialogs/export_npz_dialog.py @@ -0,0 +1,155 @@ +# ****************************************************************************** +# 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, + QHeaderView, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +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], 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) + + 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 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. + """ + if original_key == "time": + return np.asarray(self.logs[original_key]).flatten() + + arr = stack_logged_signal(self.logs, original_key) + if arr.ndim >= 2 and arr.shape[-1] == 1: + arr = arr.squeeze(-1) + return arr 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/models/project_state.py b/pySimBlocks/gui/models/project_state.py index 1c4c598..0a18289 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -57,7 +57,8 @@ 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] = {} # --- Public methods --- @@ -74,18 +75,29 @@ def clear(self): self.simulation.clear() self.external = None + self.npz_export_path = None + self.npz_key_names = {} - def load_simulation(self, sim_data: dict, external = None): + def load_simulation(self, sim_data: dict, external = None, + npz_export_path: str | None = None, + npz_key_names: dict[str, str] | 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 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 e76372f..f1e02b1 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -1155,6 +1155,25 @@ 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() # -------------------------------------------------------------------------- # Plot methods diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index a81c156..6f7b489 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -104,7 +104,10 @@ 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), ) def _load_blocks( diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index d15991f..587392a 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -183,6 +183,10 @@ 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) 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 45c8722..cfa03d7 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,6 +127,11 @@ 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) @@ -223,6 +234,32 @@ 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) + 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()) + def set_running(self, running: bool) -> None: """Enable or disable all toolbar actions based on the running state. From 880e771afcc916ca21bad5f04a64aadca79ff577 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 4 Aug 2026 17:28:52 +0200 Subject: [PATCH 8/9] feat: sofa dialog for slider --- .../gui/addons/sofa/slider_params_dialog.py | 343 ++++++++++++++++++ .../blocks/systems/sofa/sofa_exchange_i_o.py | 6 +- .../gui/blocks/systems/sofa/sofa_plant.py | 6 +- 3 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 pySimBlocks/gui/addons/sofa/slider_params_dialog.py 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/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: From 706cf66436c5d5789a1fdaee9ec3a1d843af99ab Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Tue, 4 Aug 2026 18:20:19 +0200 Subject: [PATCH 9/9] feat: add export decimation --- pySimBlocks/gui/dialogs/export_npz_dialog.py | 35 ++++++++++++++------ pySimBlocks/gui/models/project_state.py | 7 +++- pySimBlocks/gui/project_controller.py | 12 +++++++ pySimBlocks/gui/services/project_loader.py | 1 + pySimBlocks/gui/services/yaml_tools.py | 2 ++ pySimBlocks/gui/widgets/toolbar_view.py | 7 +++- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/pySimBlocks/gui/dialogs/export_npz_dialog.py b/pySimBlocks/gui/dialogs/export_npz_dialog.py index 3b442c4..5018e94 100644 --- a/pySimBlocks/gui/dialogs/export_npz_dialog.py +++ b/pySimBlocks/gui/dialogs/export_npz_dialog.py @@ -23,13 +23,8 @@ import numpy as np from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QAbstractItemView, - QDialog, - QDialogButtonBox, - QHeaderView, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, + QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QHeaderView, + QLabel, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from pySimBlocks.project.plot_series import stack_logged_signal @@ -48,7 +43,9 @@ class ExportNpzDialog(QDialog): """ def __init__(self, logs: dict[str, np.ndarray], - key_names: dict[str, str], parent=None): + key_names: dict[str, str], + decimation: int = 1, + parent=None): """Initialize the export dialog and populate it from the logs. Args: @@ -66,6 +63,18 @@ def __init__(self, logs: dict[str, np.ndarray], 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) @@ -100,6 +109,10 @@ def __init__(self, logs: dict[str, np.ndarray], # 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. @@ -146,10 +159,12 @@ def _stack(self, original_key: str) -> np.ndarray: 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": - return np.asarray(self.logs[original_key]).flatten() + 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 + return arr[::n] diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index 0a18289..261151f 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -59,6 +59,7 @@ def __init__(self, directory_path: Path): 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 --- @@ -77,10 +78,12 @@ def clear(self): self.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_key_names: dict[str, str] | None = None, + npz_decimation: int | None = None ) -> None: """Load simulation settings into the project state. @@ -98,6 +101,8 @@ def load_simulation(self, sim_data: dict, external = None, 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 f1e02b1..477d36e 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -1175,6 +1175,18 @@ def update_npz_key_names(self, mapping: dict[str, str]) -> None: 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 # -------------------------------------------------------------------------- diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 6f7b489..85bebbe 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -108,6 +108,7 @@ def _load_simulation(self, controller: ProjectController, sim_data: dict): 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 587392a..4e3ecba 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -187,6 +187,8 @@ def _build_simulation_section(project_state: ProjectState) -> dict: 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 cfa03d7..5a92b43 100644 --- a/pySimBlocks/gui/widgets/toolbar_view.py +++ b/pySimBlocks/gui/widgets/toolbar_view.py @@ -240,7 +240,11 @@ def on_export_npz(self) -> None: 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) + 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() @@ -259,6 +263,7 @@ def on_export_npz(self) -> None: 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.