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"]