Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/User_Guide/tutorial_2_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,3 @@ Run it from the command line to verify that the exported script reproduces the s

This tutorial demonstrates how to build and execute a model visually.
The next tutorials extend this approach to SOFA integration and real-time execution.

1 change: 0 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,3 @@ Each example can typically be executed either:
- Directly as a Python script
- Through the GUI-based editor `pysimblocks` (for YAML-based projects)
- Through the generated `run.py` (for GUI-based projects)

71 changes: 49 additions & 22 deletions pySimBlocks/blocks/sources/function_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import importlib.util
import inspect
from pathlib import Path
from typing import Any, Callable, Dict
from typing import Any, Callable, Dict, List

import numpy as np

Expand All @@ -38,14 +38,16 @@ class FunctionSource(BlockSource):

Notes:
- The function must accept exactly (t, dt).
- Returned value can be scalar, 1D, or 2D (internally normalized to 2D).
- Output shape is frozen after first successful evaluation.
- Function must return a dict with keys matching output_keys.
- Each output value can be scalar, 1D, or 2D (internally normalized to 2D).
- Output shape is frozen independently for each output key.
"""

def __init__(
self,
name: str,
function: Callable,
output_keys: List[str] | None = None,
sample_time: float | None = None,
):
super().__init__(name, sample_time)
Expand All @@ -54,8 +56,14 @@ def __init__(
raise TypeError(f"[{self.name}] 'function' must be callable.")

self._func = function
self._out_shape: tuple[int, int] | None = None
self.outputs["out"] = np.zeros((1, 1), dtype=float)
self.output_keys = ["out"] if output_keys is None else list(output_keys)
if len(self.output_keys) == 0:
raise ValueError(f"[{self.name}] output_keys cannot be empty.")

self.outputs: Dict[str, np.ndarray | None] = {k: None for k in self.output_keys}
self._out_shapes: Dict[str, tuple[int, int] | None] = {
k: None for k in self.output_keys
}

# --------------------------------------------------------------------------
# Class Methods
Expand Down Expand Up @@ -107,45 +115,64 @@ def adapt_params(
adapted.pop("file_path", None)
adapted.pop("function_name", None)
adapted["function"] = func
if "output_keys" not in adapted:
adapted["output_keys"] = ["out"]
return adapted

# --------------------------------------------------------------------------
# Public Methods
# --------------------------------------------------------------------------
def initialize(self, t0: float) -> None:
self._validate_signature()
self.outputs["out"] = self._call_func(t0, 0.0)
out = self._call_func(t0, 0.0)
for key in self.output_keys:
self.outputs[key] = out[key]

# ------------------------------------------------------------------
def output_update(self, t: float, dt: float) -> None:
self.outputs["out"] = self._call_func(t, dt)
out = self._call_func(t, dt)
for key in self.output_keys:
self.outputs[key] = out[key]

# --------------------------------------------------------------------------
# Private Methods
# --------------------------------------------------------------------------
def _call_func(self, t: float, dt: float) -> np.ndarray:
def _call_func(self, t: float, dt: float) -> Dict[str, np.ndarray]:
try:
y = self._func(t, dt)
out = self._func(t, dt)
except Exception as e:
raise RuntimeError(f"[{self.name}] function call error: {e}")

y = self._to_2d_array("out", y, dtype=float)
if y.ndim != 2:
raise ValueError(
f"[{self.name}] function output must be scalar, 1D, or 2D."
if not isinstance(out, dict):
raise RuntimeError(
f"[{self.name}] function must return a dict with output keys: "
f"{self.output_keys}."
)

if self._out_shape is None:
self._out_shape = y.shape
return y

if y.shape != self._out_shape:
raise ValueError(
f"[{self.name}] output 'out' shape changed: expected "
f"{self._out_shape}, got {y.shape}."
if set(out.keys()) != set(self.output_keys):
raise RuntimeError(
f"[{self.name}] output keys mismatch "
f"(expected {self.output_keys}, got {list(out.keys())})."
)

return y
normalized: Dict[str, np.ndarray] = {}
for key in self.output_keys:
y = self._to_2d_array(key, out[key], dtype=float)
if y.ndim != 2:
raise ValueError(
f"[{self.name}] output '{key}' must be scalar, 1D, or 2D."
)

if self._out_shapes[key] is None:
self._out_shapes[key] = y.shape
elif y.shape != self._out_shapes[key]:
raise ValueError(
f"[{self.name}] output '{key}' shape changed: expected "
f"{self._out_shapes[key]}, got {y.shape}."
)
normalized[key] = y

return normalized

# ------------------------------------------------------------------
def _validate_signature(self) -> None:
Expand Down
130 changes: 130 additions & 0 deletions pySimBlocks/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# ******************************************************************************
# 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 <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************

import argparse
import sys
from pathlib import Path


def _run_gui(project_dir: str | None) -> None:
from pySimBlocks.gui.editor import run_app

path = Path(project_dir).resolve() if project_dir else Path.cwd().resolve()
run_app(path)


def _run_export(args: argparse.Namespace) -> None:
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:
from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller

generate_sofa_controller(project_dir=project_dir, project_yaml=project_yaml)
else:
from pySimBlocks.project import generate_run_script

generate_run_script(project_dir=project_dir, project_yaml=project_yaml, output=output)


def _run_update() -> None:
from pySimBlocks.tools.generate_blocks_index import generate_blocks_index

print("Running pySimBlocks index update...")
generate_blocks_index()
print("pySimBlocks update complete.")


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pysimblocks",
description=(
"pySimBlocks command line interface.\n"
"Default behavior with no subcommand: launch the GUI editor."
),
)

subparsers = parser.add_subparsers(dest="command")

gui_parser = subparsers.add_parser("gui", help="Launch the GUI editor.")
gui_parser.add_argument(
"project_dir",
nargs="?",
help="Project directory to open. Defaults to current directory.",
)

export_parser = subparsers.add_parser(
"export",
help="Generate run.py or a SOFA controller from project.yaml.",
)
source_group = export_parser.add_mutually_exclusive_group()
source_group.add_argument(
"-f",
"--file",
"--project",
dest="project_file",
help="Path to project.yaml",
)
source_group.add_argument(
"-d",
"--directory",
dest="project_dir",
help="Project directory containing project.yaml",
)
export_parser.add_argument("-o", "--out", help="Output run.py path")
export_parser.add_argument(
"-s",
"--sofa-controller",
action="store_true",
help="Update SOFA controller from project.yaml instead of generating run.py.",
)

subparsers.add_parser("update", help="Regenerate pySimBlocks blocks index.")
return parser


def main(argv: list[str] | None = None) -> None:
args_list = list(sys.argv[1:] if argv is None else argv)
parser = _build_parser()

if not args_list:
_run_gui(project_dir=None)
return

if args_list[0] not in {"gui", "export", "update", "-h", "--help"}:
if len(args_list) > 1:
parser.error(f"unrecognized arguments: {' '.join(args_list[1:])}")
_run_gui(project_dir=args_list[0])
return

args = parser.parse_args(args_list)
if args.command == "gui":
_run_gui(project_dir=args.project_dir)
elif args.command == "export":
_run_export(args)
elif args.command == "update":
_run_update()
else:
parser.print_help()


if __name__ == "__main__":
main()
12 changes: 6 additions & 6 deletions pySimBlocks/docs/blocks/sources/function_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Summary

The **FunctionSource** block generates a signal from a user-defined Python function
The **FunctionSource** block generates one or more signals from a user-defined Python function
without any input ports.

At each activation, it evaluates:
Expand All @@ -22,6 +22,7 @@ since the previous activation.
|------|------|-------------|----------|
| `file_path` | string | Path to the Python file containing `f`. | Yes |
| `function_name` | string | Name of the function to call inside the file. | Yes |
| `output_keys` | list[string] | Names of the output ports. The function must return a dict with exactly these keys. | Yes |
| `sample_time` | float | Execution period of the block. If omitted, the global simulation time step is used. | No |

---
Expand All @@ -34,17 +35,16 @@ This block has **no inputs**.

## Outputs

| Port | Description |
|------|-------------|
| `out` | Function output signal. |
Outputs are dynamically defined by `output_keys`.

---

## Execution semantics

- The function signature must be exactly: `f(t, dt)`.
- The returned value may be scalar, 1D, or 2D and is normalized to a 2D array.
- The output shape is frozen after first evaluation and must stay constant.
- The function must return a dict with keys exactly matching `output_keys`.
- Each returned value may be scalar, 1D, or 2D and is normalized to a 2D array.
- Output shape is frozen per output key after first evaluation and must stay constant.
- The block is stateless.


Expand Down
17 changes: 11 additions & 6 deletions pySimBlocks/gui/addons/sofa/sofa_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,20 +140,25 @@ def run(self):

def _check_sofa_environnment(self):
sofa_root = os.environ.get("SOFA_ROOT")
sofa_py3 = os.environ.get("SOFAPYTHON3_ROOT")

if not sofa_root:
return False, "SOFA_ROOT is not set."

if not sofa_py3:
return False, "SOFAPYTHON3_ROOT is not set."

return True, "OK"

def _detect_sofa(self):
detected = shutil.which("runSofa")
detected = None
sofa_root = os.environ.get("SOFA_ROOT")
if sofa_root:
potential_path = Path(sofa_root) / "bin" / "runSofa"
if potential_path.exists():
detected = str(potential_path)

if not detected:
detected = shutil.which("runSofa")

if not detected:
detected = shutil.which("runsofa")

if detected:
self.sofa_path = detected

Expand Down
Loading