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,82 @@
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 numpy as np
22
+ from numpy.typing import ArrayLike
23
+ from pySimBlocks.core.block_source import BlockSource
24
+
25
+
26
+ class Constant(BlockSource):
27
+ """
28
+ Constant signal source block.
29
+
30
+ Summary:
31
+ Generates a constant output signal with a fixed value over time.
32
+ The output does not depend on time or any input signal.
33
+
34
+ Parameters (overview):
35
+ value : float or array-like
36
+ Constant output value. Can be scalar, vector, or matrix.
37
+ sample_time : float, optional
38
+ Block execution period.
39
+
40
+ I/O:
41
+ Inputs:
42
+ (none)
43
+ Outputs:
44
+ out : constant output signal.
45
+
46
+ Notes:
47
+ - The block has no internal state.
48
+ - The output value is held constant for the entire simulation.
49
+ - Scalar values are normalized to shape (1,1).
50
+ - 1D values are normalized to column vectors (n,1).
51
+ - 2D values are preserved as matrices (m,n).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ name: str,
57
+ value: ArrayLike = 1.0,
58
+ sample_time: float | None = None,
59
+ ):
60
+ super().__init__(name, sample_time)
61
+
62
+ # Accept numeric scalars and array-like
63
+ if not isinstance(value, (list, tuple, np.ndarray, float, int)):
64
+ raise TypeError(
65
+ f"[{self.name}] Constant 'value' must be numeric or array-like."
66
+ )
67
+
68
+ arr = self._to_2d_array("value", value, dtype=float)
69
+
70
+ self.value = arr
71
+ self.outputs["out"] = arr.copy()
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Public methods
76
+ # --------------------------------------------------------------------------
77
+ def initialize(self, t0: float) -> None:
78
+ self.outputs["out"] = self.value.copy()
79
+
80
+ # ------------------------------------------------------------------
81
+ def output_update(self, t: float, dt: float) -> None:
82
+ self.outputs["out"] = self.value.copy()
@@ -0,0 +1,294 @@
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
23
+
24
+ import numpy as np
25
+
26
+ from pySimBlocks.core.block_source import BlockSource
27
+
28
+
29
+ class FileSource(BlockSource):
30
+ """
31
+ Source block that plays samples loaded from a file.
32
+
33
+ Supported file types:
34
+ - npz: load an array from a key in a .npz archive (key mandatory)
35
+ - npy: load an array from a .npy file (no key)
36
+ - csv: load one numeric column by name (key=column name)
37
+
38
+ Output policy:
39
+ - loaded data must be 1D or 2D
40
+ - each simulation step emits one row as a column vector
41
+ - when the end is reached:
42
+ * repeat=True -> restart from first sample
43
+ * repeat=False -> output zeros
44
+ """
45
+
46
+ VALID_FILE_TYPES = {"npz", "npy", "csv"}
47
+
48
+ def __init__(
49
+ self,
50
+ name: str,
51
+ file_path: str,
52
+ key: str | None = None,
53
+ repeat: bool = False,
54
+ use_time: bool = False,
55
+ sample_time: float | None = None,
56
+ ):
57
+ super().__init__(name, sample_time)
58
+
59
+ self.file_path = str(file_path)
60
+ self.file_type = self._infer_file_type(self.file_path)
61
+ self.key = key
62
+ self.repeat = self._to_bool(repeat, "repeat")
63
+ self.use_time = self._to_bool(use_time, "use_time")
64
+
65
+ if self.use_time and self.file_type == "npy":
66
+ raise ValueError(
67
+ f"[{self.name}] use_time is supported only for NPZ and CSV inputs."
68
+ )
69
+ if self.use_time and self.repeat:
70
+ raise ValueError(
71
+ f"[{self.name}] repeat cannot be used when use_time=True."
72
+ )
73
+
74
+ self._time: np.ndarray | None = None
75
+ self._samples = self._load_samples()
76
+ self._index = 0
77
+ self._output_shape = (self._samples.shape[1], 1)
78
+
79
+ self.outputs["out"] = np.zeros(self._output_shape, dtype=float)
80
+
81
+ # --------------------------------------------------------------------------
82
+ # Class Methods
83
+ # --------------------------------------------------------------------------
84
+ @classmethod
85
+ def adapt_params(
86
+ cls,
87
+ params: Dict[str, Any],
88
+ params_dir: Path | None = None,
89
+ ) -> Dict[str, Any]:
90
+ """
91
+ Resolve relative file_path against parameters directory when provided.
92
+ """
93
+ adapted = dict(params)
94
+ file_path = adapted.get("file_path")
95
+ if file_path is None:
96
+ return adapted
97
+
98
+ path = Path(file_path).expanduser()
99
+ if not path.is_absolute() and params_dir is not None:
100
+ path = (params_dir / path).resolve()
101
+
102
+ adapted["file_path"] = str(path)
103
+ # Backward compatibility with older models that still contain file_type
104
+ adapted.pop("file_type", None)
105
+ return adapted
106
+
107
+ # --------------------------------------------------------------------------
108
+ # Public methods
109
+ # --------------------------------------------------------------------------
110
+ def initialize(self, t0: float) -> None:
111
+ if self.use_time:
112
+ self.outputs["out"] = self._current_output_at_time(t0)
113
+ else:
114
+ self._index = 0
115
+ self.outputs["out"] = self._current_output()
116
+
117
+ # ------------------------------------------------------------------
118
+ def output_update(self, t: float, dt: float) -> None:
119
+ if self.use_time:
120
+ self.outputs["out"] = self._current_output_at_time(t)
121
+ else:
122
+ self.outputs["out"] = self._current_output()
123
+ self._index += 1
124
+
125
+ # ------------------------------------------------------------------
126
+ def state_update(self, t: float, dt: float) -> None:
127
+ pass
128
+
129
+ # --------------------------------------------------------------------------
130
+ # Private methods
131
+ # --------------------------------------------------------------------------
132
+ def _load_samples(self) -> np.ndarray:
133
+ path = Path(self.file_path)
134
+ if not path.exists():
135
+ raise FileNotFoundError(f"[{self.name}] File not found: {path}")
136
+
137
+ if self.file_type == "npz":
138
+ arr, time = self._load_npz(path)
139
+ elif self.file_type == "npy":
140
+ arr, time = self._load_npy(path)
141
+ else:
142
+ arr, time = self._load_csv(path)
143
+
144
+ if arr.ndim == 1:
145
+ arr = arr.reshape(-1, 1)
146
+ elif arr.ndim != 2:
147
+ raise ValueError(
148
+ f"[{self.name}] Loaded data must be 1D or 2D. Got shape {arr.shape}."
149
+ )
150
+
151
+ if arr.shape[0] == 0:
152
+ raise ValueError(f"[{self.name}] Loaded file contains no samples.")
153
+
154
+ self._time = time
155
+
156
+ return arr.astype(float, copy=False)
157
+
158
+ # ------------------------------------------------------------------
159
+ def _load_npz(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]:
160
+ with np.load(path) as data:
161
+ keys = list(data.files)
162
+ if len(keys) == 0:
163
+ raise ValueError(f"[{self.name}] NPZ archive contains no arrays.")
164
+
165
+ selected_key = self.key
166
+ if not selected_key:
167
+ raise ValueError(
168
+ f"[{self.name}] key is mandatory for NPZ input."
169
+ )
170
+
171
+ if selected_key not in data:
172
+ raise KeyError(
173
+ f"[{self.name}] key '{selected_key}' not found in NPZ. "
174
+ f"Available keys: {keys}"
175
+ )
176
+
177
+ arr = np.asarray(data[selected_key], dtype=float)
178
+ time = None
179
+ if self.use_time:
180
+ if "time" not in data:
181
+ raise KeyError(
182
+ f"[{self.name}] use_time=True requires NPZ key 'time'."
183
+ )
184
+ time = np.asarray(data["time"], dtype=float).reshape(-1)
185
+ self._validate_time(time, arr.shape[0])
186
+ return arr, time
187
+
188
+ # ------------------------------------------------------------------
189
+ def _load_npy(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]:
190
+ if self.key not in (None, ""):
191
+ raise ValueError(
192
+ f"[{self.name}] key is not used for NPY input."
193
+ )
194
+ return np.asarray(np.load(path), dtype=float), None
195
+
196
+ # ------------------------------------------------------------------
197
+ def _load_csv(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]:
198
+ if not self.key:
199
+ raise ValueError(
200
+ f"[{self.name}] key is mandatory for CSV input and must be a column name."
201
+ )
202
+
203
+ arr = np.genfromtxt(path, delimiter=",", names=True, dtype=float)
204
+
205
+ if arr.size == 0:
206
+ raise ValueError(f"[{self.name}] CSV file is empty.")
207
+ if arr.dtype.names is None:
208
+ raise ValueError(
209
+ f"[{self.name}] CSV must contain a header row with column names."
210
+ )
211
+ if self.key not in arr.dtype.names:
212
+ raise KeyError(
213
+ f"[{self.name}] column '{self.key}' not found in CSV. "
214
+ f"Available columns: {list(arr.dtype.names)}"
215
+ )
216
+
217
+ col = np.asarray(arr[self.key], dtype=float).reshape(-1, 1)
218
+ if np.isnan(col).any():
219
+ raise ValueError(
220
+ f"[{self.name}] CSV column '{self.key}' contains non-numeric or missing values."
221
+ )
222
+ time = None
223
+ if self.use_time:
224
+ if "time" not in arr.dtype.names:
225
+ raise KeyError(
226
+ f"[{self.name}] use_time=True requires CSV column 'time'."
227
+ )
228
+ time = np.asarray(arr["time"], dtype=float).reshape(-1)
229
+ self._validate_time(time, col.shape[0])
230
+ return col, time
231
+
232
+ # ------------------------------------------------------------------
233
+ def _to_bool(self, value: bool | str, name: str) -> bool:
234
+ if isinstance(value, bool):
235
+ return value
236
+ if isinstance(value, str):
237
+ lowered = value.strip().lower()
238
+ if lowered in {"true", "1", "yes"}:
239
+ return True
240
+ if lowered in {"false", "0", "no"}:
241
+ return False
242
+ raise ValueError(f"[{self.name}] '{name}' must be a bool.")
243
+
244
+ # ------------------------------------------------------------------
245
+ def _infer_file_type(self, file_path: str) -> str:
246
+ ext = Path(file_path).suffix.lower().lstrip(".")
247
+ if ext not in self.VALID_FILE_TYPES:
248
+ raise ValueError(
249
+ f"[{self.name}] Unsupported file extension '.{ext}'. "
250
+ f"Supported extensions: {sorted(self.VALID_FILE_TYPES)}"
251
+ )
252
+ return ext
253
+
254
+ # ------------------------------------------------------------------
255
+ def _current_output(self) -> np.ndarray:
256
+ n = self._samples.shape[0]
257
+ if self._index < n:
258
+ idx = self._index
259
+ elif self.repeat:
260
+ idx = self._index % n
261
+ else:
262
+ return np.zeros(self._output_shape, dtype=float)
263
+
264
+ row = self._samples[idx]
265
+ return np.asarray(row, dtype=float).reshape(-1, 1)
266
+
267
+ # ------------------------------------------------------------------
268
+ def _current_output_at_time(self, t: float) -> np.ndarray:
269
+ if self._time is None:
270
+ raise RuntimeError(
271
+ f"[{self.name}] Internal error: use_time=True but time data is missing."
272
+ )
273
+
274
+ idx = int(np.searchsorted(self._time, t, side="right") - 1)
275
+ if idx < 0:
276
+ idx = 0
277
+
278
+ row = self._samples[idx]
279
+ return np.asarray(row, dtype=float).reshape(-1, 1)
280
+
281
+ # ------------------------------------------------------------------
282
+ def _validate_time(self, time: np.ndarray, n_samples: int) -> None:
283
+ if time.ndim != 1:
284
+ raise ValueError(f"[{self.name}] time must be a 1D array.")
285
+ if time.shape[0] != n_samples:
286
+ raise ValueError(
287
+ f"[{self.name}] time length ({time.shape[0]}) must match number of samples ({n_samples})."
288
+ )
289
+ if np.isnan(time).any():
290
+ raise ValueError(f"[{self.name}] time contains NaN values.")
291
+ if not np.all(np.diff(time) > 0.0):
292
+ raise ValueError(
293
+ f"[{self.name}] time must be strictly increasing."
294
+ )
@@ -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
+ import importlib.util
22
+ import inspect
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Dict, List
25
+
26
+ import numpy as np
27
+
28
+ from pySimBlocks.core.block_source import BlockSource
29
+
30
+
31
+ class FunctionSource(BlockSource):
32
+ """
33
+ User-defined source block with no inputs.
34
+
35
+ Summary:
36
+ Computes:
37
+ y = f(t, dt)
38
+
39
+ Notes:
40
+ - The function must accept exactly (t, dt).
41
+ - Function must return a dict with keys matching output_keys.
42
+ - Each output value can be scalar, 1D, or 2D (internally normalized to 2D).
43
+ - Output shape is frozen independently for each output key.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ name: str,
49
+ function: Callable,
50
+ output_keys: List[str] | None = None,
51
+ sample_time: float | None = None,
52
+ ):
53
+ super().__init__(name, sample_time)
54
+
55
+ if function is None or not callable(function):
56
+ raise TypeError(f"[{self.name}] 'function' must be callable.")
57
+
58
+ self._func = function
59
+ self.output_keys = ["out"] if output_keys is None else list(output_keys)
60
+ if len(self.output_keys) == 0:
61
+ raise ValueError(f"[{self.name}] output_keys cannot be empty.")
62
+
63
+ self.outputs: Dict[str, np.ndarray | None] = {k: None for k in self.output_keys}
64
+ self._out_shapes: Dict[str, tuple[int, int] | None] = {
65
+ k: None for k in self.output_keys
66
+ }
67
+
68
+ # --------------------------------------------------------------------------
69
+ # Class Methods
70
+ # --------------------------------------------------------------------------
71
+ @classmethod
72
+ def adapt_params(
73
+ cls,
74
+ params: Dict[str, Any],
75
+ params_dir: Path | None = None,
76
+ ) -> Dict[str, Any]:
77
+ """
78
+ Adapt YAML parameters by loading a callable from (file_path, function_name).
79
+ """
80
+ adapted = dict(params)
81
+
82
+ if "function" in adapted:
83
+ return adapted
84
+
85
+ has_file = "file_path" in adapted
86
+ has_name = "function_name" in adapted
87
+ if not has_file and not has_name:
88
+ return adapted
89
+ if not has_file or not has_name:
90
+ raise ValueError(
91
+ "FunctionSource adapter requires both 'file_path' and 'function_name'."
92
+ )
93
+
94
+ path = Path(adapted["file_path"]).expanduser()
95
+ if not path.is_absolute() and params_dir is not None:
96
+ path = (params_dir / path).resolve()
97
+
98
+ if not path.exists():
99
+ raise FileNotFoundError(f"Function file not found: {path}")
100
+
101
+ spec = importlib.util.spec_from_file_location(path.stem, path)
102
+ module = importlib.util.module_from_spec(spec)
103
+ assert spec.loader is not None
104
+ spec.loader.exec_module(module)
105
+
106
+ func_name = adapted["function_name"]
107
+ try:
108
+ func = getattr(module, func_name)
109
+ except AttributeError:
110
+ raise AttributeError(f"Function '{func_name}' not found in {path}")
111
+
112
+ if not callable(func):
113
+ raise TypeError(f"'{func_name}' in {path} is not callable")
114
+
115
+ adapted.pop("file_path", None)
116
+ adapted.pop("function_name", None)
117
+ adapted["function"] = func
118
+ if "output_keys" not in adapted:
119
+ adapted["output_keys"] = ["out"]
120
+ return adapted
121
+
122
+ # --------------------------------------------------------------------------
123
+ # Public Methods
124
+ # --------------------------------------------------------------------------
125
+ def initialize(self, t0: float) -> None:
126
+ self._validate_signature()
127
+ out = self._call_func(t0, 0.0)
128
+ for key in self.output_keys:
129
+ self.outputs[key] = out[key]
130
+
131
+ # ------------------------------------------------------------------
132
+ def output_update(self, t: float, dt: float) -> None:
133
+ out = self._call_func(t, dt)
134
+ for key in self.output_keys:
135
+ self.outputs[key] = out[key]
136
+
137
+ # --------------------------------------------------------------------------
138
+ # Private Methods
139
+ # --------------------------------------------------------------------------
140
+ def _call_func(self, t: float, dt: float) -> Dict[str, np.ndarray]:
141
+ try:
142
+ out = self._func(t, dt)
143
+ except Exception as e:
144
+ raise RuntimeError(f"[{self.name}] function call error: {e}")
145
+
146
+ if not isinstance(out, dict):
147
+ raise RuntimeError(
148
+ f"[{self.name}] function must return a dict with output keys: "
149
+ f"{self.output_keys}."
150
+ )
151
+
152
+ if set(out.keys()) != set(self.output_keys):
153
+ raise RuntimeError(
154
+ f"[{self.name}] output keys mismatch "
155
+ f"(expected {self.output_keys}, got {list(out.keys())})."
156
+ )
157
+
158
+ normalized: Dict[str, np.ndarray] = {}
159
+ for key in self.output_keys:
160
+ y = self._to_2d_array(key, out[key], dtype=float)
161
+ if y.ndim != 2:
162
+ raise ValueError(
163
+ f"[{self.name}] output '{key}' must be scalar, 1D, or 2D."
164
+ )
165
+
166
+ if self._out_shapes[key] is None:
167
+ self._out_shapes[key] = y.shape
168
+ elif y.shape != self._out_shapes[key]:
169
+ raise ValueError(
170
+ f"[{self.name}] output '{key}' shape changed: expected "
171
+ f"{self._out_shapes[key]}, got {y.shape}."
172
+ )
173
+ normalized[key] = y
174
+
175
+ return normalized
176
+
177
+ # ------------------------------------------------------------------
178
+ def _validate_signature(self) -> None:
179
+ sig = inspect.signature(self._func)
180
+ params = list(sig.parameters.values())
181
+
182
+ if len(params) != 2:
183
+ raise ValueError(
184
+ f"[{self.name}] function must have exactly arguments (t, dt)."
185
+ )
186
+ if params[0].name != "t" or params[1].name != "dt":
187
+ raise ValueError(
188
+ f"[{self.name}] function arguments must be exactly (t, dt)."
189
+ )
190
+
191
+ for p in params:
192
+ if p.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD,):
193
+ raise ValueError(f"[{self.name}] *args and **kwargs are not allowed.")
194
+ if p.default is not inspect.Parameter.empty:
195
+ raise ValueError(
196
+ f"[{self.name}] default values are not allowed in function signature."
197
+ )
@@ -0,0 +1,99 @@
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 numpy as np
22
+ from numpy.typing import ArrayLike
23
+ from pySimBlocks.core.block_source import BlockSource
24
+
25
+
26
+ class Ramp(BlockSource):
27
+ """
28
+ Multi-dimensional ramp signal source block (Option B).
29
+
30
+ Summary:
31
+ Generates a ramp signal element-wise on a 2D output array:
32
+ y(t) = offset + slope * max(0, t - start_time)
33
+
34
+ Parameters may be scalars, vectors, or matrices. Only scalar-to-shape
35
+ broadcasting is allowed; all non-scalar parameters must share the same
36
+ shape.
37
+
38
+ Parameters (overview):
39
+ slope : float or array-like
40
+ Ramp slope. Scalar -> broadcast; otherwise fixes output shape.
41
+ start_time : float or array-like, optional
42
+ Ramp start time. Scalar -> broadcast; otherwise must match output shape.
43
+ offset : float or array-like, optional
44
+ Output value before the ramp starts. Scalar -> broadcast; otherwise must match output shape.
45
+ sample_time : float, optional
46
+ Block execution period.
47
+
48
+ Outputs:
49
+ out : ramp output signal (2D ndarray)
50
+
51
+ Notes:
52
+ - Stateless block.
53
+ - Normalization:
54
+ scalar -> (1,1), 1D -> (n,1), 2D -> (m,n)
55
+ - Broadcasting:
56
+ Only (1,1) scalars are broadcast to the common shape.
57
+ No NumPy broadcasting beyond that.
58
+ - No implicit flattening is performed.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ slope: ArrayLike,
65
+ start_time: ArrayLike = 0.0,
66
+ offset: ArrayLike | None = None,
67
+ sample_time: float | None = None,
68
+ ):
69
+ super().__init__(name, sample_time)
70
+
71
+ S = self._to_2d_array("slope", slope, dtype=float)
72
+ T = self._to_2d_array("start_time", start_time, dtype=float)
73
+
74
+ if offset is None:
75
+ O = np.array([[0.0]], dtype=float) # scalar, will be broadcast if needed
76
+ else:
77
+ O = self._to_2d_array("offset", offset, dtype=float)
78
+
79
+ # Resolve common shape using strict scalar-only broadcasting policy
80
+ target_shape = self._resolve_common_shape({"slope": S, "start_time": T, "offset": O})
81
+
82
+ self.slope = self._broadcast_scalar_only("slope", S, target_shape)
83
+ self.start_time = self._broadcast_scalar_only("start_time", T, target_shape)
84
+ self.offset = self._broadcast_scalar_only("offset", O, target_shape)
85
+
86
+ # Output port
87
+ self.outputs["out"] = self.offset.copy()
88
+
89
+ # --------------------------------------------------------------------------
90
+ # Public methods
91
+ # --------------------------------------------------------------------------
92
+ def initialize(self, t0: float) -> None:
93
+ self.outputs["out"] = self.offset.copy()
94
+
95
+ # ------------------------------------------------------------------
96
+ def output_update(self, t: float, dt: float) -> None:
97
+ # Element-wise time shift
98
+ dt_mat = np.maximum(0.0, t - self.start_time)
99
+ self.outputs["out"] = self.offset + self.slope * dt_mat