diff --git a/docs/User_Guide/tutorial_2_gui.md b/docs/User_Guide/tutorial_2_gui.md
index 3facc3a..4cfcdac 100644
--- a/docs/User_Guide/tutorial_2_gui.md
+++ b/docs/User_Guide/tutorial_2_gui.md
@@ -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.
-
diff --git a/examples/README.md b/examples/README.md
index ae19572..09138b0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -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)
-
diff --git a/pySimBlocks/blocks/sources/function_source.py b/pySimBlocks/blocks/sources/function_source.py
index 147732b..d281b85 100644
--- a/pySimBlocks/blocks/sources/function_source.py
+++ b/pySimBlocks/blocks/sources/function_source.py
@@ -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
@@ -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)
@@ -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
@@ -107,6 +115,8 @@ 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
# --------------------------------------------------------------------------
@@ -114,38 +124,55 @@ def adapt_params(
# --------------------------------------------------------------------------
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:
diff --git a/pySimBlocks/cli.py b/pySimBlocks/cli.py
new file mode 100644
index 0000000..9de687a
--- /dev/null
+++ b/pySimBlocks/cli.py
@@ -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 .
+# ******************************************************************************
+# 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()
diff --git a/pySimBlocks/docs/blocks/sources/function_source.md b/pySimBlocks/docs/blocks/sources/function_source.md
index 1982473..34c22b5 100644
--- a/pySimBlocks/docs/blocks/sources/function_source.md
+++ b/pySimBlocks/docs/blocks/sources/function_source.md
@@ -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:
@@ -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 |
---
@@ -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.
diff --git a/pySimBlocks/gui/addons/sofa/sofa_service.py b/pySimBlocks/gui/addons/sofa/sofa_service.py
index cc12e65..00e8701 100644
--- a/pySimBlocks/gui/addons/sofa/sofa_service.py
+++ b/pySimBlocks/gui/addons/sofa/sofa_service.py
@@ -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
diff --git a/pySimBlocks/gui/blocks/sources/function_source.py b/pySimBlocks/gui/blocks/sources/function_source.py
index d5048b5..799adb5 100644
--- a/pySimBlocks/gui/blocks/sources/function_source.py
+++ b/pySimBlocks/gui/blocks/sources/function_source.py
@@ -22,11 +22,13 @@
import subprocess
import sys
from pathlib import Path
+from typing import Literal
from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton
from pySimBlocks.gui.blocks.block_meta import BlockMeta, ParameterMeta
from pySimBlocks.gui.blocks.port_meta import PortMeta
+from pySimBlocks.gui.models import BlockInstance, PortInstance
class FunctionSourceMeta(BlockMeta):
@@ -40,7 +42,8 @@ def __init__(self):
"This block evaluates a user-provided Python function with no inputs:\n\n"
" y = f(t, dt)\n\n"
"The function is loaded from an external Python file and executed at each\n"
- "activation. The output is exposed on the `out` port."
+ "activation. The function must return a dict whose keys match\n"
+ "`output_keys`. One output port is created for each key."
)
self.parameters = [
@@ -59,6 +62,14 @@ def __init__(self):
required=True,
description="Name of the function to call inside the Python file.",
),
+ ParameterMeta(
+ name="output_keys",
+ type="list[string]",
+ required=True,
+ autofill=True,
+ default=["out"],
+ description="List of output port names. The function must return a dict with exactly these keys.",
+ ),
ParameterMeta(
name="sample_time",
type="float",
@@ -69,12 +80,37 @@ def __init__(self):
self.outputs = [
PortMeta(
name="out",
- display_as="out",
- shape=["n", "m"],
- description="Function output signal.",
+ display_as="",
+ shape=[],
+ description="Function output signals defined by output_keys.",
)
]
+ # --------------------------------------------------------------------------
+ # Port resolution
+ # --------------------------------------------------------------------------
+ def resolve_port_group(
+ self,
+ port_meta: PortMeta,
+ direction: Literal["input", "output"],
+ instance: "BlockInstance",
+ ) -> list["PortInstance"]:
+ if direction == "output":
+ keys = instance.parameters.get("output_keys", [])
+ if keys is None:
+ return []
+ return [
+ PortInstance(
+ name=f"{key}",
+ display_as=key,
+ direction="output",
+ block=instance,
+ )
+ for key in keys
+ ]
+
+ return super().resolve_port_group(port_meta, direction, instance)
+
# --------------------------------------------------------------------------
# Dialog methods
# --------------------------------------------------------------------------
diff --git a/pySimBlocks/project/generate.py b/pySimBlocks/project/generate.py
deleted file mode 100644
index 195ee18..0000000
--- a/pySimBlocks/project/generate.py
+++ /dev/null
@@ -1,83 +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
-# ******************************************************************************
-
-import argparse
-from pathlib import Path
-from pySimBlocks.project import generate_run_script
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="Generate a canonical run.py for a pySimBlocks project."
- )
-
- parser.add_argument(
- "-f",
- "--file",
- "--project",
- dest="project_file",
- help="Path to project.yaml",
- )
- parser.add_argument(
- "-d",
- "--directory",
- dest="project_dir",
- help="Project directory containing project.yaml",
- )
- parser.add_argument(
- "-o",
- "--out",
- help="Output run.py path",
- )
- parser.add_argument(
- "-s",
- "--sofa-controller",
- action="store_true",
- help="Update SOFA controller from project.yaml instead of generating run.py.",
- )
-
- args = parser.parse_args()
-
- if args.project_file and args.project_dir:
- parser.error("Use either --file/-f or --directory/-d, not both.")
-
- project_yaml = None
- project_dir = None
-
- if args.project_file:
- project_yaml = Path(args.project_file)
- else:
- if args.project_dir:
- project_dir = Path(args.project_dir)
- else:
- project_dir = Path(".")
-
- 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:
- generate_run_script(
- project_dir=project_dir,
- project_yaml=project_yaml,
- output=Path(args.out) if args.out else None,
- )
diff --git a/pySimBlocks/project/pySimBlocks_blocks_index.yaml b/pySimBlocks/project/pySimBlocks_blocks_index.yaml
index 8fb196f..3bfb0b1 100644
--- a/pySimBlocks/project/pySimBlocks_blocks_index.yaml
+++ b/pySimBlocks/project/pySimBlocks_blocks_index.yaml
@@ -23,12 +23,12 @@ operators:
dead_zone:
class: DeadZone
module: pySimBlocks.blocks.operators.dead_zone
- demux:
- class: Demux
- module: pySimBlocks.blocks.operators.demux
delay:
class: Delay
module: pySimBlocks.blocks.operators.delay
+ demux:
+ class: Demux
+ module: pySimBlocks.blocks.operators.demux
discrete_derivator:
class: DiscreteDerivator
module: pySimBlocks.blocks.operators.discrete_derivator
@@ -89,12 +89,12 @@ systems:
linear_state_space:
class: LinearStateSpace
module: pySimBlocks.blocks.systems.linear_state_space
- polytopic_state_space:
- class: PolytopicStateSpace
- module: pySimBlocks.blocks.systems.polytopic_state_space
non_linear_state_space:
class: NonLinearStateSpace
module: pySimBlocks.blocks.systems.non_linear_state_space
+ polytopic_state_space:
+ class: PolytopicStateSpace
+ module: pySimBlocks.blocks.systems.polytopic_state_space
sofa_controller:
class: SofaPysimBlocksController
module: pySimBlocks.blocks.systems.sofa.sofa_controller
diff --git a/pySimBlocks/tools/cli.py b/pySimBlocks/tools/cli.py
deleted file mode 100644
index c3d9a0e..0000000
--- a/pySimBlocks/tools/cli.py
+++ /dev/null
@@ -1,34 +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
-# ******************************************************************************
-
-"""
-CLI tools for pySimBlocks maintenance.
-
-Usage:
- pysimblocks-update
-"""
-
-from pySimBlocks.tools.generate_blocks_index import generate_blocks_index
-
-
-def main():
- print("Running pySimBlocks index update...")
- generate_blocks_index()
- print("pySimBlocks update complete.")
diff --git a/pyproject.toml b/pyproject.toml
index f782675..5d77ef2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,9 +20,7 @@ dependencies = [
]
[project.scripts]
-pysimblocks = "pySimBlocks.gui.editor:main"
-pysimblocks-generate = "pySimBlocks.project.generate:main"
-pysimblocks-update = "pySimBlocks.tools.cli:main"
+pysimblocks = "pySimBlocks.cli:main"
[tool.setuptools]
include-package-data = true
diff --git a/tests/blocks/sources/test_function_source.py b/tests/blocks/sources/test_function_source.py
index 8bdbc2f..ae888ab 100644
--- a/tests/blocks/sources/test_function_source.py
+++ b/tests/blocks/sources/test_function_source.py
@@ -4,33 +4,37 @@
from pySimBlocks.blocks.sources.function_source import FunctionSource
-def test_function_source_scalar_output():
+def test_function_source_single_output():
def f(t, dt):
- return 2.0 * t + dt
+ return {"y": 2.0 * t + dt}
- src = FunctionSource(name="f", function=f)
+ src = FunctionSource(name="f", function=f, output_keys=["y"])
src.initialize(0.0)
- assert np.allclose(src.outputs["out"], [[0.0]])
+ assert np.allclose(src.outputs["y"], [[0.0]])
src.output_update(1.0, 0.1)
- assert np.allclose(src.outputs["out"], [[2.1]])
+ assert np.allclose(src.outputs["y"], [[2.1]])
-def test_function_source_vector_output_normalized_to_column():
+def test_function_source_multiple_outputs():
def f(t, dt):
- return np.array([t, t + dt])
+ return {
+ "y1": np.array([t, t + dt]),
+ "y2": np.array([[2.0 * t]]),
+ }
- src = FunctionSource(name="f", function=f)
+ src = FunctionSource(name="f", function=f, output_keys=["y1", "y2"])
src.initialize(0.0)
src.output_update(0.2, 0.1)
- assert src.outputs["out"].shape == (2, 1)
- assert np.allclose(src.outputs["out"], [[0.2], [0.3]])
+ assert src.outputs["y1"].shape == (2, 1)
+ assert np.allclose(src.outputs["y1"], [[0.2], [0.3]])
+ assert np.allclose(src.outputs["y2"], [[0.4]])
def test_function_source_signature_mismatch_raises():
def f(t, dt, u):
- return np.array([[u]])
+ return {"out": np.array([[u]])}
src = FunctionSource(name="f", function=f)
with pytest.raises(ValueError):
@@ -48,13 +52,37 @@ def f(t, dt):
assert "function call error" in str(err.value).lower()
-def test_function_source_output_shape_change_raises():
+def test_function_source_return_not_dict_raises():
+ def f(t, dt):
+ return np.array([[1.0]])
+
+ src = FunctionSource(name="f", function=f, output_keys=["out"])
+
+ with pytest.raises(RuntimeError) as err:
+ src.initialize(0.0)
+
+ assert "must return a dict" in str(err.value).lower()
+
+
+def test_function_source_output_keys_mismatch_raises():
+ def f(t, dt):
+ return {"z": np.array([[1.0]])}
+
+ src = FunctionSource(name="f", function=f, output_keys=["out"])
+
+ with pytest.raises(RuntimeError) as err:
+ src.initialize(0.0)
+
+ assert "output keys mismatch" in str(err.value).lower()
+
+
+def test_function_source_output_shape_change_raises_per_key():
def f(t, dt):
if t < 0.1:
- return np.array([[1.0]])
- return np.array([[1.0, 2.0]])
+ return {"y1": np.array([[1.0]]), "y2": np.array([[2.0]])}
+ return {"y1": np.array([[1.0, 2.0]]), "y2": np.array([[2.0]])}
- src = FunctionSource(name="f", function=f)
+ src = FunctionSource(name="f", function=f, output_keys=["y1", "y2"])
src.initialize(0.0)
with pytest.raises(ValueError) as err:
@@ -67,21 +95,41 @@ def test_function_source_adapt_params_loads_function(tmp_path):
py_file = tmp_path / "my_function.py"
py_file.write_text(
"def my_source(t, dt):\n"
- " return [[t + dt]]\n",
+ " return {'y': [[t + dt]]}\n",
encoding="utf-8",
)
adapted = FunctionSource.adapt_params(
- {"file_path": "my_function.py", "function_name": "my_source"},
+ {
+ "file_path": "my_function.py",
+ "function_name": "my_source",
+ "output_keys": ["y"],
+ },
params_dir=tmp_path,
)
src = FunctionSource(name="f", **adapted)
src.initialize(0.0)
src.output_update(0.2, 0.1)
- assert np.allclose(src.outputs["out"], [[0.3]])
+ assert np.allclose(src.outputs["y"], [[0.3]])
def test_function_source_adapt_params_missing_key_raises():
with pytest.raises(ValueError):
FunctionSource.adapt_params({"file_path": "foo.py"}, params_dir=None)
+
+
+def test_function_source_adapt_params_sets_default_output_keys(tmp_path):
+ py_file = tmp_path / "my_function.py"
+ py_file.write_text(
+ "def my_source(t, dt):\n"
+ " return {'out': [[t + dt]]}\n",
+ encoding="utf-8",
+ )
+
+ adapted = FunctionSource.adapt_params(
+ {"file_path": "my_function.py", "function_name": "my_source"},
+ params_dir=tmp_path,
+ )
+
+ assert adapted["output_keys"] == ["out"]
diff --git a/tests/gui/test_function_source_meta.py b/tests/gui/test_function_source_meta.py
index ff4c8dd..d232d5b 100644
--- a/tests/gui/test_function_source_meta.py
+++ b/tests/gui/test_function_source_meta.py
@@ -1,4 +1,5 @@
from pySimBlocks.gui.blocks.sources.function_source import FunctionSourceMeta
+from pySimBlocks.gui.models.block_instance import BlockInstance
def test_function_source_meta_definition():
@@ -9,8 +10,19 @@ def test_function_source_meta_definition():
assert [p.name for p in meta.parameters] == [
"file_path",
"function_name",
+ "output_keys",
"sample_time",
]
assert len(meta.inputs) == 0
assert len(meta.outputs) == 1
assert meta.outputs[0].name == "out"
+
+
+def test_function_source_meta_resolves_dynamic_output_ports():
+ meta = FunctionSourceMeta()
+ instance = BlockInstance(meta)
+ instance.update_params({"output_keys": ["y", "z"]})
+ instance.resolve_ports()
+
+ outputs = [p for p in instance.ports if p.direction == "output"]
+ assert [p.name for p in outputs] == ["y", "z"]