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.
Files changed (262) hide show
  1. examples/advanced/hardware/block_diagram.py +162 -0
  2. examples/advanced/hardware/camera.py +93 -0
  3. examples/advanced/hardware/gui_emio.py +231 -0
  4. examples/advanced/hardware/lmi.py +69 -0
  5. examples/advanced/hardware/main.py +57 -0
  6. examples/advanced/hardware/parameters.py +54 -0
  7. examples/advanced/qp_problem/params.py +10 -0
  8. examples/advanced/qp_problem/run.py +14 -0
  9. examples/advanced/sofa/gui/finger/Finger.py +108 -0
  10. examples/advanced/sofa/gui/finger/FingerController.py +53 -0
  11. examples/advanced/sofa/gui/finger/parameters_auto.py +22 -0
  12. examples/advanced/sofa/gui/sofa_plant/run.py +14 -0
  13. examples/advanced/sofa/python/Finger.py +107 -0
  14. examples/advanced/sofa/python/FingerController.py +48 -0
  15. examples/advanced/sofa/python/closedloop.py +64 -0
  16. examples/advanced/sofa/python/openloop.py +58 -0
  17. examples/basics/algebraic_function/g.py +7 -0
  18. examples/basics/algebraic_function/run.py +14 -0
  19. examples/basics/dc_motor/gui/run.py +14 -0
  20. examples/basics/dc_motor/multiple_order.py +91 -0
  21. examples/basics/dc_motor/openloop.py +62 -0
  22. examples/basics/dc_motor/pi.py +99 -0
  23. examples/basics/dc_motor/pid.py +118 -0
  24. examples/basics/dc_motor/pid_block.py +82 -0
  25. examples/basics/dc_motor/pid_block_vs_manual.py +143 -0
  26. examples/basics/external/parameters.py +4 -0
  27. examples/basics/external/run.py +14 -0
  28. examples/basics/file_sources/temp.py +16 -0
  29. examples/basics/observer.py +76 -0
  30. examples/basics/state_feedback/gui/parameters.py +9 -0
  31. examples/basics/state_feedback/gui/run.py +14 -0
  32. examples/basics/state_feedback/state_feedback.py +102 -0
  33. examples/basics/state_space/linear/gui/run.py +14 -0
  34. examples/basics/state_space/linear/ss_block.py +89 -0
  35. examples/basics/state_space/linear/ss_manual.py +105 -0
  36. examples/basics/state_space/non_linear/nlss.py +47 -0
  37. examples/basics/state_space/non_linear/run.py +14 -0
  38. examples/basics/state_space/polytope/params.py +39 -0
  39. examples/basics/state_space/polytope/weights.py +12 -0
  40. examples/comparison/simulink/convert.py +29 -0
  41. examples/comparison/simulink/openloop.py +127 -0
  42. examples/comparison/simulink/pi.py +148 -0
  43. examples/comparison/simulink/pid.py +154 -0
  44. examples/comparison/simulink/pid_backward.py +155 -0
  45. examples/quick_start/filter.py +35 -0
  46. examples/quick_start/gui/run.py +14 -0
  47. examples/tutorials/tutorial_1_python/main.py +99 -0
  48. examples/tutorials/tutorial_3_sofa/finger/Finger.py +120 -0
  49. examples/tutorials/tutorial_3_sofa/finger/FingerController.py +37 -0
  50. pySimBlocks/__init__.py +35 -0
  51. pySimBlocks/blocks/__init__.py +68 -0
  52. pySimBlocks/blocks/controllers/__init__.py +27 -0
  53. pySimBlocks/blocks/controllers/pid.py +250 -0
  54. pySimBlocks/blocks/controllers/state_feedback.py +141 -0
  55. pySimBlocks/blocks/interfaces/__init__.py +27 -0
  56. pySimBlocks/blocks/interfaces/external_input.py +107 -0
  57. pySimBlocks/blocks/interfaces/external_output.py +113 -0
  58. pySimBlocks/blocks/observers/__init__.py +23 -0
  59. pySimBlocks/blocks/observers/luenberger.py +175 -0
  60. pySimBlocks/blocks/operators/__init__.py +47 -0
  61. pySimBlocks/blocks/operators/algebraic_function.py +258 -0
  62. pySimBlocks/blocks/operators/dead_zone.py +186 -0
  63. pySimBlocks/blocks/operators/delay.py +254 -0
  64. pySimBlocks/blocks/operators/demux.py +123 -0
  65. pySimBlocks/blocks/operators/discrete_derivator.py +237 -0
  66. pySimBlocks/blocks/operators/discrete_integrator.py +265 -0
  67. pySimBlocks/blocks/operators/gain.py +294 -0
  68. pySimBlocks/blocks/operators/mux.py +131 -0
  69. pySimBlocks/blocks/operators/product.py +194 -0
  70. pySimBlocks/blocks/operators/rate_limiter.py +214 -0
  71. pySimBlocks/blocks/operators/saturation.py +178 -0
  72. pySimBlocks/blocks/operators/sum.py +188 -0
  73. pySimBlocks/blocks/operators/zero_order_hold.py +141 -0
  74. pySimBlocks/blocks/optimizers/__init__.py +23 -0
  75. pySimBlocks/blocks/optimizers/quadratic_program.py +326 -0
  76. pySimBlocks/blocks/sources/__init__.py +37 -0
  77. pySimBlocks/blocks/sources/chirp.py +168 -0
  78. pySimBlocks/blocks/sources/constant.py +82 -0
  79. pySimBlocks/blocks/sources/file_source.py +294 -0
  80. pySimBlocks/blocks/sources/function_source.py +197 -0
  81. pySimBlocks/blocks/sources/ramp.py +99 -0
  82. pySimBlocks/blocks/sources/sinusoidal.py +113 -0
  83. pySimBlocks/blocks/sources/step.py +138 -0
  84. pySimBlocks/blocks/sources/white_noise.py +107 -0
  85. pySimBlocks/blocks/systems/__init__.py +27 -0
  86. pySimBlocks/blocks/systems/linear_state_space.py +180 -0
  87. pySimBlocks/blocks/systems/non_linear_state_space.py +309 -0
  88. pySimBlocks/blocks/systems/polytopic_state_space.py +193 -0
  89. pySimBlocks/blocks/systems/sofa/__init__.py +30 -0
  90. pySimBlocks/blocks/systems/sofa/sofa_controller.py +472 -0
  91. pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +120 -0
  92. pySimBlocks/blocks/systems/sofa/sofa_plant.py +306 -0
  93. pySimBlocks/cli.py +127 -0
  94. pySimBlocks/core/__init__.py +32 -0
  95. pySimBlocks/core/block.py +167 -0
  96. pySimBlocks/core/block_source.py +94 -0
  97. pySimBlocks/core/config.py +90 -0
  98. pySimBlocks/core/fixed_time_manager.py +46 -0
  99. pySimBlocks/core/model.py +250 -0
  100. pySimBlocks/core/scheduler.py +30 -0
  101. pySimBlocks/core/simulator.py +329 -0
  102. pySimBlocks/core/task.py +73 -0
  103. pySimBlocks/docs/blocks/controllers/pid.md +78 -0
  104. pySimBlocks/docs/blocks/controllers/state_feedback.md +60 -0
  105. pySimBlocks/docs/blocks/interfaces/external_input.md +39 -0
  106. pySimBlocks/docs/blocks/interfaces/external_output.md +39 -0
  107. pySimBlocks/docs/blocks/observers/luenberger.md +70 -0
  108. pySimBlocks/docs/blocks/operators/algebraic_function.md +76 -0
  109. pySimBlocks/docs/blocks/operators/dead_zone.md +67 -0
  110. pySimBlocks/docs/blocks/operators/delay.md +76 -0
  111. pySimBlocks/docs/blocks/operators/demux.md +64 -0
  112. pySimBlocks/docs/blocks/operators/discrete_derivator.md +61 -0
  113. pySimBlocks/docs/blocks/operators/discrete_integrator.md +87 -0
  114. pySimBlocks/docs/blocks/operators/gain.md +97 -0
  115. pySimBlocks/docs/blocks/operators/mux.md +64 -0
  116. pySimBlocks/docs/blocks/operators/product.md +51 -0
  117. pySimBlocks/docs/blocks/operators/rate_limiter.md +70 -0
  118. pySimBlocks/docs/blocks/operators/saturation.md +62 -0
  119. pySimBlocks/docs/blocks/operators/sum.md +49 -0
  120. pySimBlocks/docs/blocks/operators/zero_order_hold.md +61 -0
  121. pySimBlocks/docs/blocks/optimizers/quadratic_program.md +98 -0
  122. pySimBlocks/docs/blocks/sources/chirp.md +102 -0
  123. pySimBlocks/docs/blocks/sources/constant.md +53 -0
  124. pySimBlocks/docs/blocks/sources/file_source.md +51 -0
  125. pySimBlocks/docs/blocks/sources/function_source.md +52 -0
  126. pySimBlocks/docs/blocks/sources/ramp.md +58 -0
  127. pySimBlocks/docs/blocks/sources/sinusoidal.md +60 -0
  128. pySimBlocks/docs/blocks/sources/step.md +63 -0
  129. pySimBlocks/docs/blocks/sources/white_noise.md +58 -0
  130. pySimBlocks/docs/blocks/systems/linear_state_space.md +66 -0
  131. pySimBlocks/docs/blocks/systems/non_linear_state_space.md +90 -0
  132. pySimBlocks/docs/blocks/systems/polytopic_state_space.md +72 -0
  133. pySimBlocks/docs/blocks/systems/sofa/sofa_exchange_i_o.md +63 -0
  134. pySimBlocks/docs/blocks/systems/sofa/sofa_plant.md +62 -0
  135. pySimBlocks/gui/__init__.py +20 -0
  136. pySimBlocks/gui/addons/__init__.py +19 -0
  137. pySimBlocks/gui/addons/sofa/__init__.py +19 -0
  138. pySimBlocks/gui/addons/sofa/sofa_dialog.py +190 -0
  139. pySimBlocks/gui/addons/sofa/sofa_service.py +209 -0
  140. pySimBlocks/gui/blocks/block_dialog_session.py +47 -0
  141. pySimBlocks/gui/blocks/block_meta.py +408 -0
  142. pySimBlocks/gui/blocks/controllers/pid.py +116 -0
  143. pySimBlocks/gui/blocks/controllers/state_feedback.py +80 -0
  144. pySimBlocks/gui/blocks/interfaces/external_input.py +64 -0
  145. pySimBlocks/gui/blocks/interfaces/external_output.py +63 -0
  146. pySimBlocks/gui/blocks/observers/luenberger.py +108 -0
  147. pySimBlocks/gui/blocks/operators/algebraic_function.py +232 -0
  148. pySimBlocks/gui/blocks/operators/dead_zone.py +79 -0
  149. pySimBlocks/gui/blocks/operators/delay.py +79 -0
  150. pySimBlocks/gui/blocks/operators/demux.py +98 -0
  151. pySimBlocks/gui/blocks/operators/discrete_derivator.py +67 -0
  152. pySimBlocks/gui/blocks/operators/discrete_integrator.py +80 -0
  153. pySimBlocks/gui/blocks/operators/gain.py +79 -0
  154. pySimBlocks/gui/blocks/operators/mux.py +100 -0
  155. pySimBlocks/gui/blocks/operators/product.py +109 -0
  156. pySimBlocks/gui/blocks/operators/rate_limiter.py +79 -0
  157. pySimBlocks/gui/blocks/operators/saturation.py +72 -0
  158. pySimBlocks/gui/blocks/operators/sum.py +91 -0
  159. pySimBlocks/gui/blocks/operators/zero_order_hold.py +64 -0
  160. pySimBlocks/gui/blocks/optimizers/quadratic_program.py +128 -0
  161. pySimBlocks/gui/blocks/parameter_meta.py +34 -0
  162. pySimBlocks/gui/blocks/port_meta.py +31 -0
  163. pySimBlocks/gui/blocks/sources/chirp.py +146 -0
  164. pySimBlocks/gui/blocks/sources/constant.py +63 -0
  165. pySimBlocks/gui/blocks/sources/file_source.py +196 -0
  166. pySimBlocks/gui/blocks/sources/function_source.py +202 -0
  167. pySimBlocks/gui/blocks/sources/ramp.py +73 -0
  168. pySimBlocks/gui/blocks/sources/sinusoidal.py +81 -0
  169. pySimBlocks/gui/blocks/sources/step.py +83 -0
  170. pySimBlocks/gui/blocks/sources/white_noise.py +70 -0
  171. pySimBlocks/gui/blocks/systems/linear_state_space.py +102 -0
  172. pySimBlocks/gui/blocks/systems/non_linear_state_space.py +233 -0
  173. pySimBlocks/gui/blocks/systems/polytopic_state_space.py +109 -0
  174. pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +221 -0
  175. pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +220 -0
  176. pySimBlocks/gui/dialogs/__init__.py +19 -0
  177. pySimBlocks/gui/dialogs/block_dialog.py +125 -0
  178. pySimBlocks/gui/dialogs/display_yaml_dialog.py +89 -0
  179. pySimBlocks/gui/dialogs/help_dialog.py +39 -0
  180. pySimBlocks/gui/dialogs/plot_dialog.py +230 -0
  181. pySimBlocks/gui/dialogs/settings/plots.py +187 -0
  182. pySimBlocks/gui/dialogs/settings/project.py +146 -0
  183. pySimBlocks/gui/dialogs/settings/simulation.py +137 -0
  184. pySimBlocks/gui/dialogs/settings_dialog.py +88 -0
  185. pySimBlocks/gui/dialogs/unsaved_dialog.py +92 -0
  186. pySimBlocks/gui/editor.py +49 -0
  187. pySimBlocks/gui/graphics/__init__.py +29 -0
  188. pySimBlocks/gui/graphics/block_item.py +289 -0
  189. pySimBlocks/gui/graphics/connection_item.py +266 -0
  190. pySimBlocks/gui/graphics/port_item.py +153 -0
  191. pySimBlocks/gui/graphics/theme.py +119 -0
  192. pySimBlocks/gui/main_window.py +162 -0
  193. pySimBlocks/gui/models/__init__.py +31 -0
  194. pySimBlocks/gui/models/block_instance.py +114 -0
  195. pySimBlocks/gui/models/connection_instance.py +43 -0
  196. pySimBlocks/gui/models/port_instance.py +52 -0
  197. pySimBlocks/gui/models/project_simulation_params.py +50 -0
  198. pySimBlocks/gui/models/project_state.py +114 -0
  199. pySimBlocks/gui/project_controller.py +302 -0
  200. pySimBlocks/gui/services/__init__.py +20 -0
  201. pySimBlocks/gui/services/project_loader.py +381 -0
  202. pySimBlocks/gui/services/project_saver.py +68 -0
  203. pySimBlocks/gui/services/simulation_runner.py +66 -0
  204. pySimBlocks/gui/services/yaml_tools.py +261 -0
  205. pySimBlocks/gui/widgets/__init__.py +19 -0
  206. pySimBlocks/gui/widgets/block_list.py +131 -0
  207. pySimBlocks/gui/widgets/diagram_view.py +312 -0
  208. pySimBlocks/gui/widgets/toolbar_view.py +161 -0
  209. pySimBlocks/project/__init__.py +35 -0
  210. pySimBlocks/project/build_model.py +92 -0
  211. pySimBlocks/project/generate_run_script.py +109 -0
  212. pySimBlocks/project/generate_sofa_controller.py +215 -0
  213. pySimBlocks/project/load_project_config.py +197 -0
  214. pySimBlocks/project/load_simulation_config.py +166 -0
  215. pySimBlocks/project/load_simulator.py +45 -0
  216. pySimBlocks/project/plot_from_config.py +174 -0
  217. pySimBlocks/project/pySimBlocks_blocks_index.yaml +106 -0
  218. pySimBlocks/real_time/__init__.py +23 -0
  219. pySimBlocks/real_time/real_time_runner.py +148 -0
  220. pySimBlocks/tools/__init__.py +19 -0
  221. pySimBlocks/tools/blocks_registry.py +141 -0
  222. pySimBlocks/tools/generate_blocks_index.py +124 -0
  223. pysimblocks-0.1.0.dist-info/METADATA +180 -0
  224. pysimblocks-0.1.0.dist-info/RECORD +262 -0
  225. pysimblocks-0.1.0.dist-info/WHEEL +5 -0
  226. pysimblocks-0.1.0.dist-info/entry_points.txt +2 -0
  227. pysimblocks-0.1.0.dist-info/licenses/LICENSE.md +165 -0
  228. pysimblocks-0.1.0.dist-info/top_level.txt +3 -0
  229. tests/blocks/controllers/test_pid.py +143 -0
  230. tests/blocks/controllers/test_state_feedback.py +193 -0
  231. tests/blocks/observers/test_luenberger.py +225 -0
  232. tests/blocks/operators/test_algebraic_function.py +230 -0
  233. tests/blocks/operators/test_dead_zone.py +106 -0
  234. tests/blocks/operators/test_delay.py +167 -0
  235. tests/blocks/operators/test_demux.py +111 -0
  236. tests/blocks/operators/test_discrete_derivator.py +160 -0
  237. tests/blocks/operators/test_discrete_integrator.py +149 -0
  238. tests/blocks/operators/test_gain.py +182 -0
  239. tests/blocks/operators/test_mux.py +101 -0
  240. tests/blocks/operators/test_product.py +171 -0
  241. tests/blocks/operators/test_rate_limiter.py +166 -0
  242. tests/blocks/operators/test_saturation.py +157 -0
  243. tests/blocks/operators/test_sum.py +183 -0
  244. tests/blocks/operators/test_zero_order_hold.py +106 -0
  245. tests/blocks/sources/test_chirp.py +157 -0
  246. tests/blocks/sources/test_constant.py +55 -0
  247. tests/blocks/sources/test_file_source.py +185 -0
  248. tests/blocks/sources/test_function_source.py +135 -0
  249. tests/blocks/sources/test_ramp.py +110 -0
  250. tests/blocks/sources/test_sinusoidal.py +113 -0
  251. tests/blocks/sources/test_step.py +76 -0
  252. tests/blocks/sources/test_white_noise.py +108 -0
  253. tests/blocks/systems/test_linear_state_space.py +135 -0
  254. tests/blocks/systems/test_polytopic_state_space.py +133 -0
  255. tests/core/test_core_errors_and_determinism.py +82 -0
  256. tests/core/test_core_multirate_tasks.py +88 -0
  257. tests/core/test_core_two_phase_and_propagation.py +132 -0
  258. tests/gui/main_window_open_test.py +162 -0
  259. tests/gui/test_block_meta_param_cache.py +16 -0
  260. tests/gui/test_file_source_meta.py +17 -0
  261. tests/gui/test_function_source_meta.py +28 -0
  262. 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