pySimBlocks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/advanced/hardware/block_diagram.py +162 -0
- examples/advanced/hardware/camera.py +93 -0
- examples/advanced/hardware/gui_emio.py +231 -0
- examples/advanced/hardware/lmi.py +69 -0
- examples/advanced/hardware/main.py +57 -0
- examples/advanced/hardware/parameters.py +54 -0
- examples/advanced/qp_problem/params.py +10 -0
- examples/advanced/qp_problem/run.py +14 -0
- examples/advanced/sofa/gui/finger/Finger.py +108 -0
- examples/advanced/sofa/gui/finger/FingerController.py +53 -0
- examples/advanced/sofa/gui/finger/parameters_auto.py +22 -0
- examples/advanced/sofa/gui/sofa_plant/run.py +14 -0
- examples/advanced/sofa/python/Finger.py +107 -0
- examples/advanced/sofa/python/FingerController.py +48 -0
- examples/advanced/sofa/python/closedloop.py +64 -0
- examples/advanced/sofa/python/openloop.py +58 -0
- examples/basics/algebraic_function/g.py +7 -0
- examples/basics/algebraic_function/run.py +14 -0
- examples/basics/dc_motor/gui/run.py +14 -0
- examples/basics/dc_motor/multiple_order.py +91 -0
- examples/basics/dc_motor/openloop.py +62 -0
- examples/basics/dc_motor/pi.py +99 -0
- examples/basics/dc_motor/pid.py +118 -0
- examples/basics/dc_motor/pid_block.py +82 -0
- examples/basics/dc_motor/pid_block_vs_manual.py +143 -0
- examples/basics/external/parameters.py +4 -0
- examples/basics/external/run.py +14 -0
- examples/basics/file_sources/temp.py +16 -0
- examples/basics/observer.py +76 -0
- examples/basics/state_feedback/gui/parameters.py +9 -0
- examples/basics/state_feedback/gui/run.py +14 -0
- examples/basics/state_feedback/state_feedback.py +102 -0
- examples/basics/state_space/linear/gui/run.py +14 -0
- examples/basics/state_space/linear/ss_block.py +89 -0
- examples/basics/state_space/linear/ss_manual.py +105 -0
- examples/basics/state_space/non_linear/nlss.py +47 -0
- examples/basics/state_space/non_linear/run.py +14 -0
- examples/basics/state_space/polytope/params.py +39 -0
- examples/basics/state_space/polytope/weights.py +12 -0
- examples/comparison/simulink/convert.py +29 -0
- examples/comparison/simulink/openloop.py +127 -0
- examples/comparison/simulink/pi.py +148 -0
- examples/comparison/simulink/pid.py +154 -0
- examples/comparison/simulink/pid_backward.py +155 -0
- examples/quick_start/filter.py +35 -0
- examples/quick_start/gui/run.py +14 -0
- examples/tutorials/tutorial_1_python/main.py +99 -0
- examples/tutorials/tutorial_3_sofa/finger/Finger.py +120 -0
- examples/tutorials/tutorial_3_sofa/finger/FingerController.py +37 -0
- pySimBlocks/__init__.py +35 -0
- pySimBlocks/blocks/__init__.py +68 -0
- pySimBlocks/blocks/controllers/__init__.py +27 -0
- pySimBlocks/blocks/controllers/pid.py +250 -0
- pySimBlocks/blocks/controllers/state_feedback.py +141 -0
- pySimBlocks/blocks/interfaces/__init__.py +27 -0
- pySimBlocks/blocks/interfaces/external_input.py +107 -0
- pySimBlocks/blocks/interfaces/external_output.py +113 -0
- pySimBlocks/blocks/observers/__init__.py +23 -0
- pySimBlocks/blocks/observers/luenberger.py +175 -0
- pySimBlocks/blocks/operators/__init__.py +47 -0
- pySimBlocks/blocks/operators/algebraic_function.py +258 -0
- pySimBlocks/blocks/operators/dead_zone.py +186 -0
- pySimBlocks/blocks/operators/delay.py +254 -0
- pySimBlocks/blocks/operators/demux.py +123 -0
- pySimBlocks/blocks/operators/discrete_derivator.py +237 -0
- pySimBlocks/blocks/operators/discrete_integrator.py +265 -0
- pySimBlocks/blocks/operators/gain.py +294 -0
- pySimBlocks/blocks/operators/mux.py +131 -0
- pySimBlocks/blocks/operators/product.py +194 -0
- pySimBlocks/blocks/operators/rate_limiter.py +214 -0
- pySimBlocks/blocks/operators/saturation.py +178 -0
- pySimBlocks/blocks/operators/sum.py +188 -0
- pySimBlocks/blocks/operators/zero_order_hold.py +141 -0
- pySimBlocks/blocks/optimizers/__init__.py +23 -0
- pySimBlocks/blocks/optimizers/quadratic_program.py +326 -0
- pySimBlocks/blocks/sources/__init__.py +37 -0
- pySimBlocks/blocks/sources/chirp.py +168 -0
- pySimBlocks/blocks/sources/constant.py +82 -0
- pySimBlocks/blocks/sources/file_source.py +294 -0
- pySimBlocks/blocks/sources/function_source.py +197 -0
- pySimBlocks/blocks/sources/ramp.py +99 -0
- pySimBlocks/blocks/sources/sinusoidal.py +113 -0
- pySimBlocks/blocks/sources/step.py +138 -0
- pySimBlocks/blocks/sources/white_noise.py +107 -0
- pySimBlocks/blocks/systems/__init__.py +27 -0
- pySimBlocks/blocks/systems/linear_state_space.py +180 -0
- pySimBlocks/blocks/systems/non_linear_state_space.py +309 -0
- pySimBlocks/blocks/systems/polytopic_state_space.py +193 -0
- pySimBlocks/blocks/systems/sofa/__init__.py +30 -0
- pySimBlocks/blocks/systems/sofa/sofa_controller.py +472 -0
- pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +120 -0
- pySimBlocks/blocks/systems/sofa/sofa_plant.py +306 -0
- pySimBlocks/cli.py +127 -0
- pySimBlocks/core/__init__.py +32 -0
- pySimBlocks/core/block.py +167 -0
- pySimBlocks/core/block_source.py +94 -0
- pySimBlocks/core/config.py +90 -0
- pySimBlocks/core/fixed_time_manager.py +46 -0
- pySimBlocks/core/model.py +250 -0
- pySimBlocks/core/scheduler.py +30 -0
- pySimBlocks/core/simulator.py +329 -0
- pySimBlocks/core/task.py +73 -0
- pySimBlocks/docs/blocks/controllers/pid.md +78 -0
- pySimBlocks/docs/blocks/controllers/state_feedback.md +60 -0
- pySimBlocks/docs/blocks/interfaces/external_input.md +39 -0
- pySimBlocks/docs/blocks/interfaces/external_output.md +39 -0
- pySimBlocks/docs/blocks/observers/luenberger.md +70 -0
- pySimBlocks/docs/blocks/operators/algebraic_function.md +76 -0
- pySimBlocks/docs/blocks/operators/dead_zone.md +67 -0
- pySimBlocks/docs/blocks/operators/delay.md +76 -0
- pySimBlocks/docs/blocks/operators/demux.md +64 -0
- pySimBlocks/docs/blocks/operators/discrete_derivator.md +61 -0
- pySimBlocks/docs/blocks/operators/discrete_integrator.md +87 -0
- pySimBlocks/docs/blocks/operators/gain.md +97 -0
- pySimBlocks/docs/blocks/operators/mux.md +64 -0
- pySimBlocks/docs/blocks/operators/product.md +51 -0
- pySimBlocks/docs/blocks/operators/rate_limiter.md +70 -0
- pySimBlocks/docs/blocks/operators/saturation.md +62 -0
- pySimBlocks/docs/blocks/operators/sum.md +49 -0
- pySimBlocks/docs/blocks/operators/zero_order_hold.md +61 -0
- pySimBlocks/docs/blocks/optimizers/quadratic_program.md +98 -0
- pySimBlocks/docs/blocks/sources/chirp.md +102 -0
- pySimBlocks/docs/blocks/sources/constant.md +53 -0
- pySimBlocks/docs/blocks/sources/file_source.md +51 -0
- pySimBlocks/docs/blocks/sources/function_source.md +52 -0
- pySimBlocks/docs/blocks/sources/ramp.md +58 -0
- pySimBlocks/docs/blocks/sources/sinusoidal.md +60 -0
- pySimBlocks/docs/blocks/sources/step.md +63 -0
- pySimBlocks/docs/blocks/sources/white_noise.md +58 -0
- pySimBlocks/docs/blocks/systems/linear_state_space.md +66 -0
- pySimBlocks/docs/blocks/systems/non_linear_state_space.md +90 -0
- pySimBlocks/docs/blocks/systems/polytopic_state_space.md +72 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_exchange_i_o.md +63 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_plant.md +62 -0
- pySimBlocks/gui/__init__.py +20 -0
- pySimBlocks/gui/addons/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/sofa_dialog.py +190 -0
- pySimBlocks/gui/addons/sofa/sofa_service.py +209 -0
- pySimBlocks/gui/blocks/block_dialog_session.py +47 -0
- pySimBlocks/gui/blocks/block_meta.py +408 -0
- pySimBlocks/gui/blocks/controllers/pid.py +116 -0
- pySimBlocks/gui/blocks/controllers/state_feedback.py +80 -0
- pySimBlocks/gui/blocks/interfaces/external_input.py +64 -0
- pySimBlocks/gui/blocks/interfaces/external_output.py +63 -0
- pySimBlocks/gui/blocks/observers/luenberger.py +108 -0
- pySimBlocks/gui/blocks/operators/algebraic_function.py +232 -0
- pySimBlocks/gui/blocks/operators/dead_zone.py +79 -0
- pySimBlocks/gui/blocks/operators/delay.py +79 -0
- pySimBlocks/gui/blocks/operators/demux.py +98 -0
- pySimBlocks/gui/blocks/operators/discrete_derivator.py +67 -0
- pySimBlocks/gui/blocks/operators/discrete_integrator.py +80 -0
- pySimBlocks/gui/blocks/operators/gain.py +79 -0
- pySimBlocks/gui/blocks/operators/mux.py +100 -0
- pySimBlocks/gui/blocks/operators/product.py +109 -0
- pySimBlocks/gui/blocks/operators/rate_limiter.py +79 -0
- pySimBlocks/gui/blocks/operators/saturation.py +72 -0
- pySimBlocks/gui/blocks/operators/sum.py +91 -0
- pySimBlocks/gui/blocks/operators/zero_order_hold.py +64 -0
- pySimBlocks/gui/blocks/optimizers/quadratic_program.py +128 -0
- pySimBlocks/gui/blocks/parameter_meta.py +34 -0
- pySimBlocks/gui/blocks/port_meta.py +31 -0
- pySimBlocks/gui/blocks/sources/chirp.py +146 -0
- pySimBlocks/gui/blocks/sources/constant.py +63 -0
- pySimBlocks/gui/blocks/sources/file_source.py +196 -0
- pySimBlocks/gui/blocks/sources/function_source.py +202 -0
- pySimBlocks/gui/blocks/sources/ramp.py +73 -0
- pySimBlocks/gui/blocks/sources/sinusoidal.py +81 -0
- pySimBlocks/gui/blocks/sources/step.py +83 -0
- pySimBlocks/gui/blocks/sources/white_noise.py +70 -0
- pySimBlocks/gui/blocks/systems/linear_state_space.py +102 -0
- pySimBlocks/gui/blocks/systems/non_linear_state_space.py +233 -0
- pySimBlocks/gui/blocks/systems/polytopic_state_space.py +109 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +221 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +220 -0
- pySimBlocks/gui/dialogs/__init__.py +19 -0
- pySimBlocks/gui/dialogs/block_dialog.py +125 -0
- pySimBlocks/gui/dialogs/display_yaml_dialog.py +89 -0
- pySimBlocks/gui/dialogs/help_dialog.py +39 -0
- pySimBlocks/gui/dialogs/plot_dialog.py +230 -0
- pySimBlocks/gui/dialogs/settings/plots.py +187 -0
- pySimBlocks/gui/dialogs/settings/project.py +146 -0
- pySimBlocks/gui/dialogs/settings/simulation.py +137 -0
- pySimBlocks/gui/dialogs/settings_dialog.py +88 -0
- pySimBlocks/gui/dialogs/unsaved_dialog.py +92 -0
- pySimBlocks/gui/editor.py +49 -0
- pySimBlocks/gui/graphics/__init__.py +29 -0
- pySimBlocks/gui/graphics/block_item.py +289 -0
- pySimBlocks/gui/graphics/connection_item.py +266 -0
- pySimBlocks/gui/graphics/port_item.py +153 -0
- pySimBlocks/gui/graphics/theme.py +119 -0
- pySimBlocks/gui/main_window.py +162 -0
- pySimBlocks/gui/models/__init__.py +31 -0
- pySimBlocks/gui/models/block_instance.py +114 -0
- pySimBlocks/gui/models/connection_instance.py +43 -0
- pySimBlocks/gui/models/port_instance.py +52 -0
- pySimBlocks/gui/models/project_simulation_params.py +50 -0
- pySimBlocks/gui/models/project_state.py +114 -0
- pySimBlocks/gui/project_controller.py +302 -0
- pySimBlocks/gui/services/__init__.py +20 -0
- pySimBlocks/gui/services/project_loader.py +381 -0
- pySimBlocks/gui/services/project_saver.py +68 -0
- pySimBlocks/gui/services/simulation_runner.py +66 -0
- pySimBlocks/gui/services/yaml_tools.py +261 -0
- pySimBlocks/gui/widgets/__init__.py +19 -0
- pySimBlocks/gui/widgets/block_list.py +131 -0
- pySimBlocks/gui/widgets/diagram_view.py +312 -0
- pySimBlocks/gui/widgets/toolbar_view.py +161 -0
- pySimBlocks/project/__init__.py +35 -0
- pySimBlocks/project/build_model.py +92 -0
- pySimBlocks/project/generate_run_script.py +109 -0
- pySimBlocks/project/generate_sofa_controller.py +215 -0
- pySimBlocks/project/load_project_config.py +197 -0
- pySimBlocks/project/load_simulation_config.py +166 -0
- pySimBlocks/project/load_simulator.py +45 -0
- pySimBlocks/project/plot_from_config.py +174 -0
- pySimBlocks/project/pySimBlocks_blocks_index.yaml +106 -0
- pySimBlocks/real_time/__init__.py +23 -0
- pySimBlocks/real_time/real_time_runner.py +148 -0
- pySimBlocks/tools/__init__.py +19 -0
- pySimBlocks/tools/blocks_registry.py +141 -0
- pySimBlocks/tools/generate_blocks_index.py +124 -0
- pysimblocks-0.1.0.dist-info/METADATA +180 -0
- pysimblocks-0.1.0.dist-info/RECORD +262 -0
- pysimblocks-0.1.0.dist-info/WHEEL +5 -0
- pysimblocks-0.1.0.dist-info/entry_points.txt +2 -0
- pysimblocks-0.1.0.dist-info/licenses/LICENSE.md +165 -0
- pysimblocks-0.1.0.dist-info/top_level.txt +3 -0
- tests/blocks/controllers/test_pid.py +143 -0
- tests/blocks/controllers/test_state_feedback.py +193 -0
- tests/blocks/observers/test_luenberger.py +225 -0
- tests/blocks/operators/test_algebraic_function.py +230 -0
- tests/blocks/operators/test_dead_zone.py +106 -0
- tests/blocks/operators/test_delay.py +167 -0
- tests/blocks/operators/test_demux.py +111 -0
- tests/blocks/operators/test_discrete_derivator.py +160 -0
- tests/blocks/operators/test_discrete_integrator.py +149 -0
- tests/blocks/operators/test_gain.py +182 -0
- tests/blocks/operators/test_mux.py +101 -0
- tests/blocks/operators/test_product.py +171 -0
- tests/blocks/operators/test_rate_limiter.py +166 -0
- tests/blocks/operators/test_saturation.py +157 -0
- tests/blocks/operators/test_sum.py +183 -0
- tests/blocks/operators/test_zero_order_hold.py +106 -0
- tests/blocks/sources/test_chirp.py +157 -0
- tests/blocks/sources/test_constant.py +55 -0
- tests/blocks/sources/test_file_source.py +185 -0
- tests/blocks/sources/test_function_source.py +135 -0
- tests/blocks/sources/test_ramp.py +110 -0
- tests/blocks/sources/test_sinusoidal.py +113 -0
- tests/blocks/sources/test_step.py +76 -0
- tests/blocks/sources/test_white_noise.py +108 -0
- tests/blocks/systems/test_linear_state_space.py +135 -0
- tests/blocks/systems/test_polytopic_state_space.py +133 -0
- tests/core/test_core_errors_and_determinism.py +82 -0
- tests/core/test_core_multirate_tasks.py +88 -0
- tests/core/test_core_two_phase_and_propagation.py +132 -0
- tests/gui/main_window_open_test.py +162 -0
- tests/gui/test_block_meta_param_cache.py +16 -0
- tests/gui/test_file_source_meta.py +17 -0
- tests/gui/test_function_source_meta.py +28 -0
- tests/regression/test_simulink_dc_motor.py +217 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# ******************************************************************************
|
|
2
|
+
# pySimBlocks
|
|
3
|
+
# Copyright (c) 2026 Université de Lille & INRIA
|
|
4
|
+
# ******************************************************************************
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify it
|
|
6
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
8
|
+
# option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
11
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
12
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
|
13
|
+
# for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
16
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
# ******************************************************************************
|
|
18
|
+
# Authors: see Authors.txt
|
|
19
|
+
# ******************************************************************************
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from multiprocessing import Process, Pipe
|
|
23
|
+
import numpy as np
|
|
24
|
+
from typing import Any, Dict, List
|
|
25
|
+
from pySimBlocks.core.block import Block
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sofa_worker(conn, scene_file, input_keys, output_keys):
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import Sofa
|
|
32
|
+
import importlib.util
|
|
33
|
+
|
|
34
|
+
scene_dir = os.path.dirname(os.path.abspath(scene_file))
|
|
35
|
+
if scene_dir not in sys.path:
|
|
36
|
+
sys.path.insert(0, scene_dir)
|
|
37
|
+
|
|
38
|
+
# 1. Import scene
|
|
39
|
+
spec = importlib.util.spec_from_file_location("scene", scene_file)
|
|
40
|
+
mod = importlib.util.module_from_spec(spec)
|
|
41
|
+
spec.loader.exec_module(mod)
|
|
42
|
+
|
|
43
|
+
root = Sofa.Core.Node("root")
|
|
44
|
+
root, controller = mod.createScene(root)
|
|
45
|
+
|
|
46
|
+
sofa_outputs_keys = set(controller.outputs.keys())
|
|
47
|
+
if not set(output_keys).issubset(sofa_outputs_keys):
|
|
48
|
+
conn.send({
|
|
49
|
+
"cmd": "error",
|
|
50
|
+
"message": (
|
|
51
|
+
f"\n[pySimBlocks] ERROR: Output key not found in controller outputs.\n"
|
|
52
|
+
f"Available keys: {sofa_outputs_keys}\n"
|
|
53
|
+
f"Provided keys: {set(output_keys)}\n"
|
|
54
|
+
f"Check the 'output_keys' parameter in your project.yaml."
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
conn.close()
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
sofa_inputs_keys = set(controller.inputs.keys())
|
|
61
|
+
if not set(input_keys).issubset(sofa_inputs_keys):
|
|
62
|
+
conn.send({
|
|
63
|
+
"cmd": "error",
|
|
64
|
+
"message": (
|
|
65
|
+
f"[pySimBlocks] ERROR: Input key not found in controller inputs.\n"
|
|
66
|
+
f"Available keys: {sofa_inputs_keys}\n"
|
|
67
|
+
f"Provided keys: {set(input_keys)}\n"
|
|
68
|
+
f"Check the 'input_keys' parameter in your project.yaml."
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
conn.close()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
controller.SOFA_MASTER = False
|
|
75
|
+
Sofa.Simulation.initRoot(root)
|
|
76
|
+
|
|
77
|
+
dt = float(root.dt.value)
|
|
78
|
+
|
|
79
|
+
while not controller.IS_READY:
|
|
80
|
+
controller.prepare_scene()
|
|
81
|
+
if controller.IS_READY:
|
|
82
|
+
break
|
|
83
|
+
Sofa.Simulation.animate(root, dt)
|
|
84
|
+
|
|
85
|
+
# Send initial outputs
|
|
86
|
+
try:
|
|
87
|
+
controller.get_outputs()
|
|
88
|
+
initial = {k: np.asarray(controller.outputs[k]).reshape(-1,1) for k in output_keys}
|
|
89
|
+
conn.send(initial)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
conn.send({
|
|
92
|
+
"cmd": "error",
|
|
93
|
+
"message":
|
|
94
|
+
f"[pySimBlocks] ERROR: Failed to get initial outputs.\n{e}"})
|
|
95
|
+
conn.close()
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# 2. Main loop
|
|
99
|
+
while True:
|
|
100
|
+
msg = conn.recv()
|
|
101
|
+
|
|
102
|
+
if msg["cmd"] == "step":
|
|
103
|
+
try:
|
|
104
|
+
for key, val in msg["inputs"].items():
|
|
105
|
+
controller.inputs[key] = val
|
|
106
|
+
|
|
107
|
+
controller.set_inputs()
|
|
108
|
+
Sofa.Simulation.animate(root, dt)
|
|
109
|
+
controller.get_outputs()
|
|
110
|
+
|
|
111
|
+
outputs = {k: np.asarray(controller.outputs[k]).reshape(-1,1)
|
|
112
|
+
for k in output_keys}
|
|
113
|
+
conn.send(outputs)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
conn.send({
|
|
116
|
+
"cmd": "error",
|
|
117
|
+
"message": f"[pySimBlocks] ERROR during step execution.\n{e}"})
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
elif msg["cmd"] == "stop":
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
conn.close()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SofaPlant(Block):
|
|
127
|
+
"""
|
|
128
|
+
SOFA-based dynamic plant block.
|
|
129
|
+
|
|
130
|
+
Summary:
|
|
131
|
+
Executes a SOFA simulation as a dynamic system driven by pySimBlocks.
|
|
132
|
+
At each control step, inputs are sent to SOFA, the scene advances,
|
|
133
|
+
and updated outputs are returned.
|
|
134
|
+
|
|
135
|
+
Parameters (overview):
|
|
136
|
+
scene_file : str
|
|
137
|
+
Path to the SOFA scene file.
|
|
138
|
+
input_keys : list[str]
|
|
139
|
+
Names of input signals exchanged with SOFA.
|
|
140
|
+
output_keys : list[str]
|
|
141
|
+
Names of output signals produced by SOFA.
|
|
142
|
+
sample_time : float, optional
|
|
143
|
+
Block execution period.
|
|
144
|
+
|
|
145
|
+
I/O:
|
|
146
|
+
Inputs:
|
|
147
|
+
Defined dynamically by input_keys.
|
|
148
|
+
Outputs:
|
|
149
|
+
Defined dynamically by output_keys.
|
|
150
|
+
|
|
151
|
+
Notes:
|
|
152
|
+
- This block runs SOFA in a separate worker process.
|
|
153
|
+
- The block has internal state and no direct feedthrough.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
direct_feedthrough = False
|
|
157
|
+
need_first = True
|
|
158
|
+
|
|
159
|
+
def __init__(self,
|
|
160
|
+
name: str,
|
|
161
|
+
scene_file: str,
|
|
162
|
+
input_keys: list[str],
|
|
163
|
+
output_keys: list[str],
|
|
164
|
+
slider_params: Dict[str, List[float]] | None = None,
|
|
165
|
+
sample_time: float | None = None
|
|
166
|
+
):
|
|
167
|
+
super().__init__(name, sample_time)
|
|
168
|
+
|
|
169
|
+
self.scene_file = scene_file
|
|
170
|
+
self.input_keys = input_keys
|
|
171
|
+
self.output_keys = output_keys
|
|
172
|
+
self.slider_params = slider_params
|
|
173
|
+
|
|
174
|
+
for k in input_keys:
|
|
175
|
+
self.inputs[k] = None
|
|
176
|
+
self.next_outputs = {}
|
|
177
|
+
for k in output_keys:
|
|
178
|
+
self.outputs[k] = None
|
|
179
|
+
self.state[k] = None
|
|
180
|
+
self.next_state[k] = None
|
|
181
|
+
|
|
182
|
+
self.process = None
|
|
183
|
+
self.conn = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
# Class Methods
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
@classmethod
|
|
190
|
+
def adapt_params(cls,
|
|
191
|
+
params: Dict[str, Any],
|
|
192
|
+
params_dir: Path | None = None) -> Dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
Adapt parameters from yaml format to class constructor format.
|
|
195
|
+
Adapt function file and name in a yaml format into callable.
|
|
196
|
+
"""
|
|
197
|
+
if params_dir is None:
|
|
198
|
+
raise ValueError("params_dir must be provided for SofaPlant adaptation")
|
|
199
|
+
|
|
200
|
+
scene_file = params.get("scene_file")
|
|
201
|
+
if scene_file is None:
|
|
202
|
+
raise ValueError("Missing 'scene_file' parameter")
|
|
203
|
+
|
|
204
|
+
path = Path(scene_file).expanduser()
|
|
205
|
+
if not path.is_absolute():
|
|
206
|
+
path = (params_dir / path).resolve()
|
|
207
|
+
|
|
208
|
+
adapted = dict(params)
|
|
209
|
+
adapted["scene_file"] = str(path)
|
|
210
|
+
|
|
211
|
+
return adapted
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# Public Methods
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
def initialize(self, t0: float):
|
|
218
|
+
|
|
219
|
+
# Start worker
|
|
220
|
+
parent_conn, child_conn = Pipe()
|
|
221
|
+
self.conn = parent_conn
|
|
222
|
+
|
|
223
|
+
self.process = Process(
|
|
224
|
+
target=sofa_worker,
|
|
225
|
+
args=(child_conn, self.scene_file,
|
|
226
|
+
self.input_keys, self.output_keys)
|
|
227
|
+
)
|
|
228
|
+
self.process.start()
|
|
229
|
+
|
|
230
|
+
# Receive initial outputs
|
|
231
|
+
initial_outputs = self.conn.recv()
|
|
232
|
+
|
|
233
|
+
if isinstance(initial_outputs, dict) and initial_outputs.get("cmd") == "error":
|
|
234
|
+
raise RuntimeError(initial_outputs["message"])
|
|
235
|
+
|
|
236
|
+
for k in self.output_keys:
|
|
237
|
+
self.outputs[k] = initial_outputs[k]
|
|
238
|
+
self.state[k] = initial_outputs[k]
|
|
239
|
+
self.next_state[k] = initial_outputs[k]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
def output_update(self, t: float, dt: float):
|
|
244
|
+
"""
|
|
245
|
+
Outputs were already updated during the previous state_update().
|
|
246
|
+
This block retrieves outputs from an external SOFA worker process,
|
|
247
|
+
so no computation is required here.
|
|
248
|
+
"""
|
|
249
|
+
for key in self.output_keys:
|
|
250
|
+
self.outputs[key] = self.state[key]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
def state_update(self, t: float, dt: float):
|
|
255
|
+
|
|
256
|
+
# Send inputs
|
|
257
|
+
msg = {"cmd": "step", "inputs": {}}
|
|
258
|
+
for k in self.input_keys:
|
|
259
|
+
val = self.inputs[k]
|
|
260
|
+
if val is None:
|
|
261
|
+
raise RuntimeError(
|
|
262
|
+
f"[{self.name}] Input '{k}' is missing at time {t}."
|
|
263
|
+
)
|
|
264
|
+
msg["inputs"][k] = val
|
|
265
|
+
|
|
266
|
+
self.conn.send(msg)
|
|
267
|
+
|
|
268
|
+
# Receive outputs
|
|
269
|
+
outputs = self.conn.recv()
|
|
270
|
+
if isinstance(outputs, dict) and outputs.get("cmd") == "error":
|
|
271
|
+
raise RuntimeError(outputs["message"])
|
|
272
|
+
|
|
273
|
+
for k in self.output_keys:
|
|
274
|
+
self.next_state[k] = outputs[k]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
def finalize(self):
|
|
279
|
+
"""Ensure worker process is shutdown cleanly."""
|
|
280
|
+
if self.conn:
|
|
281
|
+
try:
|
|
282
|
+
self.conn.send({"cmd": "stop"})
|
|
283
|
+
except:
|
|
284
|
+
pass
|
|
285
|
+
try:
|
|
286
|
+
self.conn.close()
|
|
287
|
+
except:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
if self.process:
|
|
291
|
+
self.process.join(timeout=1.0)
|
|
292
|
+
if self.process.is_alive():
|
|
293
|
+
self.process.kill()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --------------------------------------------------------------------------
|
|
297
|
+
# Destructor
|
|
298
|
+
# --------------------------------------------------------------------------
|
|
299
|
+
def __del__(self):
|
|
300
|
+
if self.conn:
|
|
301
|
+
try:
|
|
302
|
+
self.conn.send({"cmd": "stop"})
|
|
303
|
+
except:
|
|
304
|
+
pass
|
|
305
|
+
if self.process:
|
|
306
|
+
self.process.join(timeout=0.5)
|
pySimBlocks/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# ******************************************************************************
|
|
2
|
+
# pySimBlocks
|
|
3
|
+
# Copyright (c) 2026 Université de Lille & INRIA
|
|
4
|
+
# ******************************************************************************
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify it
|
|
6
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
8
|
+
# option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
11
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
12
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
|
13
|
+
# for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
16
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
# ******************************************************************************
|
|
18
|
+
# Authors: see Authors.txt
|
|
19
|
+
# ******************************************************************************
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_gui(project_dir: str | None) -> None:
|
|
27
|
+
try:
|
|
28
|
+
from pySimBlocks.gui.editor import run_app
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
print(f"Error importing GUI modules: {e}")
|
|
31
|
+
print("Make sure PySide6 is installed to use the GUI editor.")
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
path = Path(project_dir).resolve() if project_dir else Path.cwd().resolve()
|
|
35
|
+
run_app(path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_export(args: argparse.Namespace) -> None:
|
|
39
|
+
project_yaml = Path(args.project_file) if args.project_file else None
|
|
40
|
+
project_dir = Path(args.project_dir) if args.project_dir else Path(".")
|
|
41
|
+
output = Path(args.out) if args.out else None
|
|
42
|
+
|
|
43
|
+
if args.sofa_controller:
|
|
44
|
+
try:
|
|
45
|
+
from pySimBlocks.project.generate_sofa_controller import generate_sofa_controller
|
|
46
|
+
generate_sofa_controller(project_dir=project_dir, project_yaml=project_yaml)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Error generating SOFA controller: {e}")
|
|
49
|
+
print("See SOFA integration documentation for troubleshooting.")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
else:
|
|
52
|
+
from pySimBlocks.project import generate_run_script
|
|
53
|
+
|
|
54
|
+
generate_run_script(project_dir=project_dir, project_yaml=project_yaml, output=output)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_update() -> None:
|
|
58
|
+
from pySimBlocks.tools.generate_blocks_index import generate_blocks_index
|
|
59
|
+
|
|
60
|
+
print("Running pySimBlocks index update...")
|
|
61
|
+
generate_blocks_index()
|
|
62
|
+
print("pySimBlocks update complete.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
66
|
+
parser = argparse.ArgumentParser(
|
|
67
|
+
prog="pysimblocks",
|
|
68
|
+
description=(
|
|
69
|
+
"pySimBlocks command line interface."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
74
|
+
|
|
75
|
+
gui_parser = subparsers.add_parser("gui", help="Launch the GUI editor.")
|
|
76
|
+
gui_parser.add_argument(
|
|
77
|
+
"project_dir",
|
|
78
|
+
nargs="?",
|
|
79
|
+
help="Project directory to open. Defaults to current directory.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export_parser = subparsers.add_parser(
|
|
83
|
+
"export",
|
|
84
|
+
help="Generate run.py or a SOFA controller from project.yaml.",
|
|
85
|
+
)
|
|
86
|
+
source_group = export_parser.add_mutually_exclusive_group()
|
|
87
|
+
source_group.add_argument(
|
|
88
|
+
"-f",
|
|
89
|
+
"--file",
|
|
90
|
+
"--project",
|
|
91
|
+
dest="project_file",
|
|
92
|
+
help="Path to project.yaml",
|
|
93
|
+
)
|
|
94
|
+
source_group.add_argument(
|
|
95
|
+
"-d",
|
|
96
|
+
"--directory",
|
|
97
|
+
dest="project_dir",
|
|
98
|
+
help="Project directory containing project.yaml",
|
|
99
|
+
)
|
|
100
|
+
export_parser.add_argument("-o", "--out", help="Output run.py path")
|
|
101
|
+
export_parser.add_argument(
|
|
102
|
+
"-s",
|
|
103
|
+
"--sofa-controller",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Update SOFA controller from project.yaml instead of generating run.py.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
subparsers.add_parser("update", help="Regenerate pySimBlocks blocks index.")
|
|
109
|
+
return parser
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: list[str] | None = None) -> None:
|
|
113
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
114
|
+
parser = _build_parser()
|
|
115
|
+
args = parser.parse_args(args_list)
|
|
116
|
+
if args.command == "gui":
|
|
117
|
+
_run_gui(project_dir=args.project_dir)
|
|
118
|
+
elif args.command == "export":
|
|
119
|
+
_run_export(args)
|
|
120
|
+
elif args.command == "update":
|
|
121
|
+
_run_update()
|
|
122
|
+
else:
|
|
123
|
+
parser.print_help()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ******************************************************************************
|
|
2
|
+
# pySimBlocks
|
|
3
|
+
# Copyright (c) 2026 Université de Lille & INRIA
|
|
4
|
+
# ******************************************************************************
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify it
|
|
6
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
8
|
+
# option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
11
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
12
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
|
13
|
+
# for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
16
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
# ******************************************************************************
|
|
18
|
+
# Authors: see Authors.txt
|
|
19
|
+
# ******************************************************************************
|
|
20
|
+
|
|
21
|
+
from pySimBlocks.core.block import Block
|
|
22
|
+
from pySimBlocks.core.config import SimulationConfig, PlotConfig
|
|
23
|
+
from pySimBlocks.core.model import Model
|
|
24
|
+
from pySimBlocks.core.simulator import Simulator
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Block",
|
|
28
|
+
"Model",
|
|
29
|
+
"PlotConfig",
|
|
30
|
+
"SimulationConfig",
|
|
31
|
+
"Simulator"
|
|
32
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# ******************************************************************************
|
|
2
|
+
# pySimBlocks
|
|
3
|
+
# Copyright (c) 2026 Université de Lille & INRIA
|
|
4
|
+
# ******************************************************************************
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify it
|
|
6
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
8
|
+
# option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
11
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
12
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
|
|
13
|
+
# for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
16
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
# ******************************************************************************
|
|
18
|
+
# Authors: see Authors.txt
|
|
19
|
+
# ******************************************************************************
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Block(ABC):
|
|
29
|
+
"""
|
|
30
|
+
Base class for all discrete-time blocks (Simulink-like).
|
|
31
|
+
|
|
32
|
+
A block follows two-phase execution:
|
|
33
|
+
|
|
34
|
+
1) output_update(t, dt):
|
|
35
|
+
Computes outputs y[k] from:
|
|
36
|
+
- current state x[k]
|
|
37
|
+
- current inputs u[k]
|
|
38
|
+
|
|
39
|
+
2) state_update(t, dt):
|
|
40
|
+
Computes next state x[k+1] from:
|
|
41
|
+
- current state x[k]
|
|
42
|
+
- current inputs u[k]
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
direct_feedthrough = True
|
|
46
|
+
is_source = False
|
|
47
|
+
|
|
48
|
+
def __init__(self, name: str, sample_time: float | None = None):
|
|
49
|
+
self.name = name
|
|
50
|
+
|
|
51
|
+
if sample_time is not None and sample_time <= 0:
|
|
52
|
+
raise ValueError(f"[{self.name}] sample_time must be > 0.")
|
|
53
|
+
self.sample_time = sample_time
|
|
54
|
+
|
|
55
|
+
# Dict[str -> np.ndarray]
|
|
56
|
+
self.inputs = {} # ports set by the simulator
|
|
57
|
+
self.outputs = {} # ports produced at each step
|
|
58
|
+
|
|
59
|
+
# Internal states:
|
|
60
|
+
# state: x[k] (committed state)
|
|
61
|
+
# next_state: x[k+1] (to commit at end of step)
|
|
62
|
+
self.state = {}
|
|
63
|
+
self.next_state = {}
|
|
64
|
+
|
|
65
|
+
self._effective_sample_time = 0.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------
|
|
69
|
+
# Class Methods
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
@classmethod
|
|
72
|
+
def adapt_params(cls,
|
|
73
|
+
params: Dict[str, Any],
|
|
74
|
+
params_dir: Path | None = None) -> Dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Adapt parameters from yaml format to class constructor format.
|
|
77
|
+
By default, does nothing.
|
|
78
|
+
"""
|
|
79
|
+
return params
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
# Public methods
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
@property
|
|
86
|
+
def has_state(self) -> bool:
|
|
87
|
+
"""Specify if block is stateful."""
|
|
88
|
+
return bool(self.state) or bool(self.next_state)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def initialize(self, t0: float):
|
|
93
|
+
"""
|
|
94
|
+
Initialize internal state x[0] and outputs y[0].
|
|
95
|
+
Must fill:
|
|
96
|
+
- self.state[...] (initial state)
|
|
97
|
+
- self.outputs[...] (initial outputs)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def output_update(self, t: float, dt: float):
|
|
103
|
+
"""
|
|
104
|
+
Compute outputs y[k] from x[k] and inputs u[k].
|
|
105
|
+
Called before state_update.
|
|
106
|
+
Must write to self.outputs[...].
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def state_update(self, t: float, dt: float):
|
|
112
|
+
"""
|
|
113
|
+
Compute next state x[k+1] from x[k] and inputs u[k].
|
|
114
|
+
Must write to self.next_state[...].
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
def commit_state(self):
|
|
119
|
+
"""
|
|
120
|
+
Finalize the step by copying x[k+1] into x[k].
|
|
121
|
+
Called by the simulator after all blocks completed state_update().
|
|
122
|
+
"""
|
|
123
|
+
for key, value in self.next_state.items():
|
|
124
|
+
self.state[key] = np.copy(value)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
def finalize(self):
|
|
128
|
+
"""
|
|
129
|
+
Optional cleanup method called at the end of the simulation.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------------
|
|
134
|
+
# Private methods
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _is_scalar_2d(arr: np.ndarray) -> bool:
|
|
138
|
+
return arr.shape == (1, 1)
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
def _to_2d_array(self, param_name: str, value, *, dtype=float) -> np.ndarray:
|
|
142
|
+
"""
|
|
143
|
+
Normalize into a 2D NumPy array.
|
|
144
|
+
|
|
145
|
+
Rules:
|
|
146
|
+
- scalar -> (1,1)
|
|
147
|
+
- 1D -> (n,1) (column vector convention)
|
|
148
|
+
- 2D -> preserved as-is (m,n)
|
|
149
|
+
- ndim > 2 -> rejected
|
|
150
|
+
"""
|
|
151
|
+
arr = np.asarray(value, dtype=dtype)
|
|
152
|
+
|
|
153
|
+
if arr.ndim == 0:
|
|
154
|
+
return arr.reshape(1, 1)
|
|
155
|
+
|
|
156
|
+
if arr.ndim == 1:
|
|
157
|
+
return arr.reshape(-1, 1)
|
|
158
|
+
|
|
159
|
+
if arr.ndim == 2:
|
|
160
|
+
if arr.shape[0] == 1 and arr.shape[1] != 1:
|
|
161
|
+
return arr.reshape(-1, 1)
|
|
162
|
+
return arr
|
|
163
|
+
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"[{self.name}] '{param_name}' must be scalar, 1D, or 2D array-like. "
|
|
166
|
+
f"Got ndim={arr.ndim} with shape {arr.shape}."
|
|
167
|
+
)
|