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,109 @@
|
|
|
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
|
+
|
|
23
|
+
|
|
24
|
+
RUN_TEMPLATE = """\
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from pySimBlocks.project import load_simulator_from_project
|
|
27
|
+
from pySimBlocks.project.plot_from_config import plot_from_config
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
BASE_DIR = Path(__file__).parent.resolve()
|
|
31
|
+
except Exception:
|
|
32
|
+
BASE_DIR = Path("")
|
|
33
|
+
|
|
34
|
+
sim, plot_cfg = load_simulator_from_project(BASE_DIR / {project_path!r})
|
|
35
|
+
|
|
36
|
+
logs = sim.run()
|
|
37
|
+
if {enable_plots} and plot_cfg is not None:
|
|
38
|
+
plot_from_config(logs, plot_cfg)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def generate_python_content(
|
|
42
|
+
project_yaml_path: str,
|
|
43
|
+
enable_plots: bool = True,
|
|
44
|
+
) -> str:
|
|
45
|
+
return RUN_TEMPLATE.format(
|
|
46
|
+
project_path=project_yaml_path,
|
|
47
|
+
enable_plots=enable_plots,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_run_script(
|
|
53
|
+
*,
|
|
54
|
+
project_dir: Path | None = None,
|
|
55
|
+
project_yaml: Path | None = None,
|
|
56
|
+
output: Path | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Generate a canonical run.py script for a pySimBlocks project.
|
|
60
|
+
|
|
61
|
+
Exactly one of the following modes must be used:
|
|
62
|
+
- project_dir (project.yaml expected)
|
|
63
|
+
- project_yaml
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
project_dir : Path, optional
|
|
68
|
+
Path to a project folder containing project.yaml.
|
|
69
|
+
|
|
70
|
+
project_yaml : Path, optional
|
|
71
|
+
Path to project.yaml (explicit unified mode).
|
|
72
|
+
|
|
73
|
+
output : Path, optional
|
|
74
|
+
Output run.py path (default: <project_dir>/run.py).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
has_project_yaml_mode = project_yaml is not None
|
|
78
|
+
|
|
79
|
+
if project_dir and has_project_yaml_mode:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"Cannot use project_dir with project_yaml."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not project_dir and not has_project_yaml_mode:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"You must specify one mode: project_dir or project_yaml."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Unified project_dir mode
|
|
90
|
+
if project_dir:
|
|
91
|
+
project_dir = Path(project_dir)
|
|
92
|
+
project_yaml = project_dir / "project.yaml"
|
|
93
|
+
output = output or (project_dir / "run.py")
|
|
94
|
+
if not project_yaml.exists():
|
|
95
|
+
raise FileNotFoundError(f"project.yaml not found: {project_yaml}")
|
|
96
|
+
|
|
97
|
+
content = generate_python_content(project_yaml_path=project_yaml.name)
|
|
98
|
+
|
|
99
|
+
# Unified explicit project_yaml mode
|
|
100
|
+
elif has_project_yaml_mode:
|
|
101
|
+
project_yaml = Path(project_yaml)
|
|
102
|
+
output = Path(output or "run.py")
|
|
103
|
+
if not project_yaml.exists():
|
|
104
|
+
raise FileNotFoundError(f"project.yaml not found: {project_yaml}")
|
|
105
|
+
|
|
106
|
+
content = generate_python_content(project_yaml_path=str(project_yaml))
|
|
107
|
+
|
|
108
|
+
output.write_text(content)
|
|
109
|
+
print(f"[pySimBlocks] run script generated: {output}")
|
|
@@ -0,0 +1,215 @@
|
|
|
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 importlib.util
|
|
22
|
+
import inspect
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from multiprocessing import Pipe, Process
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_scene_in_subprocess(scene_path, conn):
|
|
33
|
+
"""
|
|
34
|
+
Load SOFA scene in subprocess and return controller file path.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
scene_path = Path(scene_path).resolve()
|
|
38
|
+
scene_dir = scene_path.parent
|
|
39
|
+
if str(scene_dir) not in sys.path:
|
|
40
|
+
sys.path.insert(0, str(scene_dir))
|
|
41
|
+
|
|
42
|
+
spec = importlib.util.spec_from_file_location("scene", scene_path)
|
|
43
|
+
mod = importlib.util.module_from_spec(spec)
|
|
44
|
+
spec.loader.exec_module(mod)
|
|
45
|
+
|
|
46
|
+
import Sofa
|
|
47
|
+
root = Sofa.Core.Node("root")
|
|
48
|
+
|
|
49
|
+
out = mod.createScene(root)
|
|
50
|
+
if not isinstance(out, (list, tuple)) or len(out) < 2:
|
|
51
|
+
conn.send(None)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
controller = out[1]
|
|
55
|
+
controller_file = inspect.getsourcefile(controller.__class__)
|
|
56
|
+
conn.send(controller_file)
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"Error {e}")
|
|
60
|
+
conn.send(None)
|
|
61
|
+
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_controller_file_from_scene(scene_file: Path) -> Path:
|
|
67
|
+
"""
|
|
68
|
+
Automatically get controller path from scene.
|
|
69
|
+
"""
|
|
70
|
+
parent_conn, child_conn = Pipe()
|
|
71
|
+
p = Process(target=_load_scene_in_subprocess, args=(scene_file, child_conn))
|
|
72
|
+
p.start()
|
|
73
|
+
try:
|
|
74
|
+
controller_path = parent_conn.recv()
|
|
75
|
+
except EOFError:
|
|
76
|
+
controller_path = None
|
|
77
|
+
p.join()
|
|
78
|
+
|
|
79
|
+
if controller_path is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"Unable to determine controller file from scene {scene_file}. "
|
|
82
|
+
"Ensure createScene(root) returns (root, controller)."
|
|
83
|
+
)
|
|
84
|
+
return Path(controller_path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def inject_base_dir(src: str) -> str:
|
|
88
|
+
if "BASE_DIR = Path(__file__).resolve().parent" in src:
|
|
89
|
+
return src
|
|
90
|
+
|
|
91
|
+
injection = (
|
|
92
|
+
"from pathlib import Path\n\n"
|
|
93
|
+
"BASE_DIR = Path(__file__).resolve().parent\n\n"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
import_block = list(re.finditer(r"^(import|from)\s+.+$", src, re.MULTILINE))
|
|
97
|
+
if import_block:
|
|
98
|
+
last = import_block[-1]
|
|
99
|
+
insert_at = last.end()
|
|
100
|
+
return src[:insert_at] + "\n\n" + injection + src[insert_at:]
|
|
101
|
+
|
|
102
|
+
return injection + src
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def inject_project_path_into_controller(
|
|
106
|
+
controller_file: Path,
|
|
107
|
+
project_yaml: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Inject or replace project_yaml attribute inside SofaPysimBlocksController __init__.
|
|
111
|
+
"""
|
|
112
|
+
src = controller_file.read_text()
|
|
113
|
+
src = inject_base_dir(src)
|
|
114
|
+
|
|
115
|
+
controller_dir = controller_file.parent
|
|
116
|
+
project_yaml = project_yaml.resolve()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
rel_project = Path(os.path.relpath(project_yaml, controller_dir))
|
|
120
|
+
project_expr = f"(BASE_DIR / {rel_project.as_posix()!r}).resolve()"
|
|
121
|
+
except ValueError:
|
|
122
|
+
project_expr = f"Path({project_yaml.as_posix()!r}).resolve()"
|
|
123
|
+
|
|
124
|
+
expr = f"self.project_yaml = str({project_expr})"
|
|
125
|
+
|
|
126
|
+
pattern = r"self\.project_yaml\s*=.*"
|
|
127
|
+
if re.search(pattern, src):
|
|
128
|
+
src = re.sub(pattern, expr, src)
|
|
129
|
+
else:
|
|
130
|
+
src = src.replace(
|
|
131
|
+
"super().__init__(name=name)",
|
|
132
|
+
f"super().__init__(name=name)\n {expr}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
controller_file.write_text(src)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_project_yaml(project_yaml: Path) -> dict:
|
|
139
|
+
if not project_yaml.exists():
|
|
140
|
+
raise FileNotFoundError(f"project.yaml not found: {project_yaml}")
|
|
141
|
+
|
|
142
|
+
raw = yaml.safe_load(project_yaml.read_text()) or {}
|
|
143
|
+
if not isinstance(raw, dict):
|
|
144
|
+
raise ValueError("project.yaml must define a YAML mapping")
|
|
145
|
+
return raw
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _find_sofa_block(raw_project: dict) -> dict:
|
|
149
|
+
diagram = raw_project.get("diagram", {})
|
|
150
|
+
if not isinstance(diagram, dict):
|
|
151
|
+
raise ValueError("'diagram' section must be a mapping")
|
|
152
|
+
|
|
153
|
+
blocks = diagram.get("blocks", [])
|
|
154
|
+
if not isinstance(blocks, list):
|
|
155
|
+
raise ValueError("'diagram.blocks' section must be a list")
|
|
156
|
+
|
|
157
|
+
sofa_block = next(
|
|
158
|
+
(
|
|
159
|
+
b
|
|
160
|
+
for b in blocks
|
|
161
|
+
if isinstance(b, dict)
|
|
162
|
+
and str(b.get("type", "")).lower() in ("sofa_plant", "sofa_exchange_i_o")
|
|
163
|
+
),
|
|
164
|
+
None,
|
|
165
|
+
)
|
|
166
|
+
if sofa_block is None:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"No SofaPlant or SofaExchangeIO block found in project.yaml"
|
|
169
|
+
)
|
|
170
|
+
return sofa_block
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _resolve_scene_file(project_yaml: Path, sofa_block: dict) -> Path:
|
|
174
|
+
params = sofa_block.get("parameters", {})
|
|
175
|
+
if not isinstance(params, dict):
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"'diagram.blocks[{sofa_block.get('name', '?')}].parameters' must be a mapping"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
scene_file = params.get("scene_file", None)
|
|
181
|
+
if not isinstance(scene_file, str) or not scene_file:
|
|
182
|
+
raise KeyError(
|
|
183
|
+
f"'scene_file' must be defined in parameters for block '{sofa_block.get('name', '?')}'"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
path = Path(scene_file).expanduser()
|
|
187
|
+
if not path.is_absolute():
|
|
188
|
+
path = (project_yaml.parent / path).resolve()
|
|
189
|
+
return path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def generate_sofa_controller(
|
|
193
|
+
project_dir: Path | None = None,
|
|
194
|
+
project_yaml: Path | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
has_project_path = project_yaml is not None
|
|
197
|
+
|
|
198
|
+
if project_dir and has_project_path:
|
|
199
|
+
raise ValueError("Cannot use project_dir together with project_yaml.")
|
|
200
|
+
|
|
201
|
+
if not project_dir and not has_project_path:
|
|
202
|
+
raise ValueError("You must specify either project_dir or project_yaml.")
|
|
203
|
+
|
|
204
|
+
if project_dir:
|
|
205
|
+
project_yaml = Path(project_dir).resolve() / "project.yaml"
|
|
206
|
+
else:
|
|
207
|
+
project_yaml = Path(project_yaml).resolve()
|
|
208
|
+
|
|
209
|
+
raw_project = _load_project_yaml(project_yaml)
|
|
210
|
+
sofa_block = _find_sofa_block(raw_project)
|
|
211
|
+
scene_file = _resolve_scene_file(project_yaml, sofa_block)
|
|
212
|
+
controller_file = detect_controller_file_from_scene(scene_file)
|
|
213
|
+
|
|
214
|
+
inject_project_path_into_controller(controller_file, project_yaml)
|
|
215
|
+
print(f"[pySimBlocks] SOFA controller updated: {controller_file}")
|
|
@@ -0,0 +1,197 @@
|
|
|
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 typing import Any, Dict, Tuple
|
|
23
|
+
|
|
24
|
+
from pySimBlocks.core.config import PlotConfig, SimulationConfig
|
|
25
|
+
from pySimBlocks.project.load_simulation_config import (
|
|
26
|
+
_check_no_external_refs,
|
|
27
|
+
_load_external_module,
|
|
28
|
+
_load_yaml,
|
|
29
|
+
_resolve_external_refs,
|
|
30
|
+
eval_recursive,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_schema_version(raw: Dict[str, Any]) -> None:
|
|
35
|
+
schema_version = raw.get("schema_version", None)
|
|
36
|
+
if schema_version != 1:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Unsupported or missing 'schema_version': {schema_version!r}. "
|
|
39
|
+
"Expected: 1"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_scope(raw: Dict[str, Any], project_yaml: Path) -> Tuple[Any, Dict[str, Any]]:
|
|
44
|
+
simulation = raw.get("simulation", {})
|
|
45
|
+
if not isinstance(simulation, dict):
|
|
46
|
+
raise ValueError("'simulation' section must be a mapping")
|
|
47
|
+
|
|
48
|
+
external_module_path = simulation.get("external_module", None)
|
|
49
|
+
if external_module_path is None:
|
|
50
|
+
_check_no_external_refs(raw)
|
|
51
|
+
return None, {}
|
|
52
|
+
|
|
53
|
+
if not isinstance(external_module_path, str):
|
|
54
|
+
raise ValueError("'simulation.external_module' must be a path to a Python file")
|
|
55
|
+
|
|
56
|
+
external_path = Path(external_module_path).expanduser()
|
|
57
|
+
if not external_path.is_absolute():
|
|
58
|
+
external_path = project_yaml.parent / external_path
|
|
59
|
+
external_module, scope = _load_external_module(external_path)
|
|
60
|
+
return external_module, scope
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_plot_config(sim_data: Dict[str, Any]) -> PlotConfig | None:
|
|
64
|
+
plots_data = sim_data.get("plots", None)
|
|
65
|
+
if plots_data is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if not isinstance(plots_data, list):
|
|
69
|
+
raise ValueError("'simulation.plots' section must be a list")
|
|
70
|
+
|
|
71
|
+
plot_cfg = PlotConfig(plots=plots_data)
|
|
72
|
+
plot_cfg.validate()
|
|
73
|
+
return plot_cfg
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _adapt_diagram_to_model_dict(
|
|
77
|
+
diagram_data: Dict[str, Any],
|
|
78
|
+
scope: Dict[str, Any],
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
blocks = diagram_data.get("blocks", [])
|
|
81
|
+
if not isinstance(blocks, list):
|
|
82
|
+
raise ValueError("'diagram.blocks' section must be a list")
|
|
83
|
+
|
|
84
|
+
model_blocks: list[dict[str, Any]] = []
|
|
85
|
+
for desc in blocks:
|
|
86
|
+
if not isinstance(desc, dict):
|
|
87
|
+
raise ValueError("Each block in 'diagram.blocks' must be a mapping")
|
|
88
|
+
|
|
89
|
+
name = desc.get("name")
|
|
90
|
+
category = desc.get("category")
|
|
91
|
+
block_type = desc.get("type")
|
|
92
|
+
if not isinstance(name, str) or not isinstance(category, str) or not isinstance(block_type, str):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Each block in 'diagram.blocks' must define string fields: "
|
|
95
|
+
"'name', 'category', and 'type'"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
params_raw = desc.get("parameters", {})
|
|
99
|
+
if not isinstance(params_raw, dict):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"'diagram.blocks[{name}].parameters' must be a mapping"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
model_blocks.append(
|
|
105
|
+
{
|
|
106
|
+
"name": name,
|
|
107
|
+
"category": category,
|
|
108
|
+
"type": block_type,
|
|
109
|
+
"parameters": eval_recursive(params_raw, scope),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
connections_raw = diagram_data.get("connections", [])
|
|
114
|
+
if not isinstance(connections_raw, list):
|
|
115
|
+
raise ValueError("'diagram.connections' section must be a list")
|
|
116
|
+
|
|
117
|
+
model_connections: list[list[str]] = []
|
|
118
|
+
for conn in connections_raw:
|
|
119
|
+
if not isinstance(conn, dict):
|
|
120
|
+
raise ValueError("Each connection in 'diagram.connections' must be a mapping")
|
|
121
|
+
|
|
122
|
+
ports = conn.get("ports", None)
|
|
123
|
+
if not isinstance(ports, list) or len(ports) != 2:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"Each connection in 'diagram.connections' must define "
|
|
126
|
+
"'ports: [src, dst]'"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
src, dst = ports
|
|
130
|
+
if not isinstance(src, str) or not isinstance(dst, str):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"Connection ports in 'diagram.connections' must be strings"
|
|
133
|
+
)
|
|
134
|
+
model_connections.append([src, dst])
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"blocks": model_blocks,
|
|
138
|
+
"connections": model_connections,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_project_config(
|
|
143
|
+
project_yaml: str | Path,
|
|
144
|
+
) -> Tuple[SimulationConfig, Dict[str, Any], PlotConfig | None, str, Path]:
|
|
145
|
+
"""
|
|
146
|
+
Load a full pySimBlocks unified project.yaml configuration.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
(SimulationConfig, model_dict, PlotConfig | None, project_name, params_dir)
|
|
150
|
+
"""
|
|
151
|
+
project_yaml = Path(project_yaml)
|
|
152
|
+
raw = _load_yaml(project_yaml)
|
|
153
|
+
|
|
154
|
+
_validate_schema_version(raw)
|
|
155
|
+
|
|
156
|
+
external_module, scope = _load_scope(raw, project_yaml)
|
|
157
|
+
|
|
158
|
+
resolved = _resolve_external_refs(raw, external_module) if external_module else raw
|
|
159
|
+
|
|
160
|
+
project_data = resolved.get("project", {})
|
|
161
|
+
if not isinstance(project_data, dict):
|
|
162
|
+
raise ValueError("'project' section must be a mapping")
|
|
163
|
+
project_name = project_data.get("name", "model")
|
|
164
|
+
if not isinstance(project_name, str) or not project_name.strip():
|
|
165
|
+
raise ValueError("'project.name' must be a non-empty string")
|
|
166
|
+
|
|
167
|
+
sim_data = resolved.get("simulation", {})
|
|
168
|
+
if not isinstance(sim_data, dict):
|
|
169
|
+
raise ValueError("'simulation' section must be a mapping")
|
|
170
|
+
|
|
171
|
+
required = {"dt", "T"}
|
|
172
|
+
missing = required - sim_data.keys()
|
|
173
|
+
if missing:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Missing required simulation parameters in 'simulation': {sorted(missing)}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
sim_eval_data = eval_recursive(sim_data, scope)
|
|
179
|
+
sim_cfg = SimulationConfig(
|
|
180
|
+
dt=sim_eval_data["dt"],
|
|
181
|
+
T=sim_eval_data["T"],
|
|
182
|
+
t0=sim_eval_data.get("t0", 0.0),
|
|
183
|
+
solver=sim_eval_data.get("solver", "fixed"),
|
|
184
|
+
logging=sim_data.get("logging", []),
|
|
185
|
+
clock=sim_eval_data.get("clock", "internal"),
|
|
186
|
+
)
|
|
187
|
+
sim_cfg.validate()
|
|
188
|
+
|
|
189
|
+
plot_cfg = _build_plot_config(sim_data)
|
|
190
|
+
|
|
191
|
+
diagram_data = resolved.get("diagram", {})
|
|
192
|
+
if not isinstance(diagram_data, dict):
|
|
193
|
+
raise ValueError("'diagram' section must be a mapping")
|
|
194
|
+
|
|
195
|
+
model_dict = _adapt_diagram_to_model_dict(diagram_data, scope)
|
|
196
|
+
|
|
197
|
+
return sim_cfg, model_dict, plot_cfg, project_name, project_yaml.parent.resolve()
|
|
@@ -0,0 +1,166 @@
|
|
|
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 importlib.util
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, Any, Tuple
|
|
24
|
+
import yaml
|
|
25
|
+
import numpy as np
|
|
26
|
+
import re
|
|
27
|
+
from pySimBlocks.core.config import SimulationConfig
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------
|
|
32
|
+
def _load_yaml(path: Path) -> Dict[str, Any]:
|
|
33
|
+
if not path.exists():
|
|
34
|
+
raise FileNotFoundError(f"Project file not found: {path}")
|
|
35
|
+
|
|
36
|
+
with path.open("r") as f:
|
|
37
|
+
data = yaml.safe_load(f) or {}
|
|
38
|
+
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise ValueError("project.yaml must define a YAML mapping")
|
|
41
|
+
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
############################################################
|
|
47
|
+
# External variables
|
|
48
|
+
############################################################
|
|
49
|
+
def _load_external_module(path: Path):
|
|
50
|
+
if not path.exists():
|
|
51
|
+
raise FileNotFoundError(f"External parameters module not found: {path}")
|
|
52
|
+
|
|
53
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
54
|
+
module = importlib.util.module_from_spec(spec)
|
|
55
|
+
|
|
56
|
+
assert spec.loader is not None
|
|
57
|
+
spec.loader.exec_module(module)
|
|
58
|
+
|
|
59
|
+
return module, module.__dict__
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_EXTERNAL_REF_PATTERN = re.compile(r"#([A-Za-z_][A-Za-z0-9_]*)")
|
|
63
|
+
def extract_external_refs(expr: str) -> set[str]:
|
|
64
|
+
"""
|
|
65
|
+
Extract all external references (#var) from an expression string.
|
|
66
|
+
"""
|
|
67
|
+
return set(_EXTERNAL_REF_PATTERN.findall(expr))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_external_refs(obj: Any, external_module) -> Any:
|
|
71
|
+
"""
|
|
72
|
+
Recursively validate #var references using the external module.
|
|
73
|
+
Does NOT replace anything.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(obj, str):
|
|
76
|
+
refs = extract_external_refs(obj)
|
|
77
|
+
for name in refs:
|
|
78
|
+
if not hasattr(external_module, name):
|
|
79
|
+
raise KeyError(
|
|
80
|
+
f"External parameter '{name}' not found "
|
|
81
|
+
f"in module '{external_module.__file__}'"
|
|
82
|
+
)
|
|
83
|
+
return obj
|
|
84
|
+
|
|
85
|
+
if isinstance(obj, list):
|
|
86
|
+
return [_resolve_external_refs(v, external_module) for v in obj]
|
|
87
|
+
|
|
88
|
+
if isinstance(obj, dict):
|
|
89
|
+
return {
|
|
90
|
+
k: _resolve_external_refs(v, external_module)
|
|
91
|
+
for k, v in obj.items()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return obj
|
|
95
|
+
|
|
96
|
+
def _check_no_external_refs(obj):
|
|
97
|
+
if isinstance(obj, str):
|
|
98
|
+
refs = extract_external_refs(obj)
|
|
99
|
+
if refs:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Found external references {sorted(refs)} "
|
|
102
|
+
"but no external module is defined"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
elif isinstance(obj, list):
|
|
106
|
+
for v in obj:
|
|
107
|
+
_check_no_external_refs(v)
|
|
108
|
+
|
|
109
|
+
elif isinstance(obj, dict):
|
|
110
|
+
for v in obj.values():
|
|
111
|
+
_check_no_external_refs(v)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
############################################################
|
|
116
|
+
# EVAL
|
|
117
|
+
############################################################
|
|
118
|
+
def eval_value(value: Any, scope: dict):
|
|
119
|
+
"""
|
|
120
|
+
Try to evaluate ANY value as a Python expression.
|
|
121
|
+
|
|
122
|
+
Rules:
|
|
123
|
+
- value is first converted to string
|
|
124
|
+
- '#' is stripped (used as internal keyword)
|
|
125
|
+
- lists are wrapped into np.array
|
|
126
|
+
- eval is attempted with a restricted namespace
|
|
127
|
+
- if eval fails -> return original value
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
expr = str(value)
|
|
132
|
+
expr = expr.replace("#", "")
|
|
133
|
+
expr = re.sub(r'(?<!np\.array)\[', 'np.array([', expr)
|
|
134
|
+
expr = re.sub(r'\]', '])', expr)
|
|
135
|
+
return eval(expr, {"np": np}, scope)
|
|
136
|
+
except Exception:
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def eval_recursive(obj: Any, scope: dict):
|
|
141
|
+
if isinstance(obj, dict):
|
|
142
|
+
return {k: eval_recursive(v, scope) for k, v in obj.items()}
|
|
143
|
+
|
|
144
|
+
if isinstance(obj, list):
|
|
145
|
+
return [eval_recursive(v, scope) for v in obj]
|
|
146
|
+
|
|
147
|
+
return eval_value(obj, scope)
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------
|
|
150
|
+
# Public API
|
|
151
|
+
# ---------------------------------------------------------------------
|
|
152
|
+
def load_simulation_config(
|
|
153
|
+
project_yaml: str | Path,
|
|
154
|
+
) -> Tuple[SimulationConfig, Dict[str, Any], Path]:
|
|
155
|
+
"""
|
|
156
|
+
Load simulation and diagram configuration from unified project.yaml.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
(SimulationConfig, model_dict, params_dir)
|
|
160
|
+
"""
|
|
161
|
+
from pySimBlocks.project.load_project_config import load_project_config
|
|
162
|
+
|
|
163
|
+
sim_cfg, model_dict, _plot_cfg, _project_name, params_dir = load_project_config(
|
|
164
|
+
project_yaml
|
|
165
|
+
)
|
|
166
|
+
return sim_cfg, model_dict, params_dir
|