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,214 @@
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
+
24
+ from pySimBlocks.core.block import Block
25
+
26
+
27
+ class RateLimiter(Block):
28
+ """
29
+ Discrete-time rate limiter block.
30
+
31
+ Summary:
32
+ Limits the rate of change of the output signal by constraining the
33
+ maximum allowed increase and decrease per time step.
34
+
35
+ Parameters:
36
+ rising_slope : scalar or array-like, optional
37
+ Maximum allowed positive rate of change (>= 0).
38
+ falling_slope : scalar or array-like, optional
39
+ Maximum allowed negative rate of change (<= 0).
40
+ initial_output : scalar or array-like, optional
41
+ Initial output y(-1). If not provided, y(-1) = u(0).
42
+ sample_time : float, optional
43
+ Block execution period.
44
+
45
+ Inputs:
46
+ in : array (m,n)
47
+ Input signal (must be 2D).
48
+
49
+ Outputs:
50
+ out : array (m,n)
51
+ Rate-limited output signal.
52
+
53
+ Notes:
54
+ - Stateful block.
55
+ - Direct feedthrough.
56
+ - Broadcasting rules (to match input shape (m,n)):
57
+ * scalar (1,1) broadcasts to (m,n)
58
+ * vector (m,1) broadcasts across columns to (m,n)
59
+ * matrix (m,n) must match exactly
60
+ - Once resolved, input shape must remain constant.
61
+ """
62
+
63
+ direct_feedthrough = True
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ rising_slope: ArrayLike = np.inf,
69
+ falling_slope: ArrayLike = -np.inf,
70
+ initial_output: ArrayLike | None = None,
71
+ sample_time: float | None = None,
72
+ ):
73
+ super().__init__(name, sample_time)
74
+
75
+ self.inputs["in"] = None
76
+ self.outputs["out"] = None
77
+
78
+ # Raw parameters (2D normalized)
79
+ self.rising_raw = self._to_2d_array("rising_slope", rising_slope)
80
+ self.falling_raw = self._to_2d_array("falling_slope", falling_slope)
81
+ self.y0_raw = None if initial_output is None else self._to_2d_array("initial_output", initial_output)
82
+
83
+ # Basic sign constraints (raw)
84
+ if np.any(self.rising_raw < 0):
85
+ raise ValueError(f"[{self.name}] rising_slope must be >= 0.")
86
+ if np.any(self.falling_raw > 0):
87
+ raise ValueError(f"[{self.name}] falling_slope must be <= 0.")
88
+
89
+ # Resolved parameters (broadcasted to input shape once known)
90
+ self.rising_slope: ArrayLike | None = None
91
+ self.falling_slope: ArrayLike | None = None
92
+ self._resolved_shape: tuple[int, int] | None = None
93
+
94
+ # State
95
+ self.state["y"] = None
96
+ self.next_state["y"] = None
97
+
98
+
99
+ # --------------------------------------------------------------------------
100
+ # Public methods
101
+ # --------------------------------------------------------------------------
102
+ def initialize(self, t0: float) -> None:
103
+ u = self.inputs["in"]
104
+ if u is None:
105
+ raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.")
106
+
107
+ u = np.asarray(u, dtype=float)
108
+ if u.ndim != 2:
109
+ raise ValueError(
110
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
111
+ )
112
+
113
+ self._resolve_for_input(u)
114
+
115
+ if self.y0_raw is not None:
116
+ y0 = self._broadcast_param(self.y0_raw, u.shape, "initial_output")
117
+ else:
118
+ y0 = u.copy()
119
+
120
+ self.state["y"] = y0.copy()
121
+ self.outputs["out"] = y0.copy()
122
+
123
+ # ------------------------------------------------------------------
124
+ def output_update(self, t: float, dt: float) -> None:
125
+ u = self.inputs["in"]
126
+ if u is None:
127
+ raise RuntimeError(f"[{self.name}] Input 'in' is None.")
128
+
129
+ u = np.asarray(u, dtype=float)
130
+ if u.ndim != 2:
131
+ raise ValueError(
132
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
133
+ )
134
+
135
+ if self.state["y"] is None:
136
+ raise RuntimeError(f"[{self.name}] RateLimiter not initialized (state 'y' is None).")
137
+
138
+ self._resolve_for_input(u)
139
+
140
+ y_prev = self.state["y"]
141
+ if y_prev.shape != u.shape:
142
+ # extra safety: state shape must match input shape
143
+ raise ValueError(
144
+ f"[{self.name}] Internal state shape mismatch: y has shape {y_prev.shape}, input has shape {u.shape}."
145
+ )
146
+
147
+ du = u - y_prev
148
+ du_min = self.falling_slope * dt
149
+ du_max = self.rising_slope * dt
150
+
151
+ du_limited = np.clip(du, du_min, du_max)
152
+ self.outputs["out"] = y_prev + du_limited
153
+
154
+ # ------------------------------------------------------------------
155
+ def state_update(self, t: float, dt: float) -> None:
156
+ self.next_state["y"] = None if self.outputs["out"] is None else self.outputs["out"].copy()
157
+
158
+
159
+ # --------------------------------------------------------------------------
160
+ # Private methods
161
+ # --------------------------------------------------------------------------
162
+ def _broadcast_param(self, p: np.ndarray, target_shape: tuple[int, int], name: str) -> np.ndarray:
163
+ """
164
+ Broadcast p to target_shape using explicit rules:
165
+ - scalar (1,1) -> (m,n)
166
+ - vector (m,1) -> repeat along columns to (m,n)
167
+ - matrix (m,n) -> exact match
168
+ """
169
+ m, n = target_shape
170
+
171
+ if self._is_scalar_2d(p):
172
+ return np.full(target_shape, float(p[0, 0]), dtype=float)
173
+
174
+ if p.ndim == 2 and p.shape[1] == 1 and p.shape[0] == m:
175
+ if n == 1:
176
+ return p.astype(float, copy=False)
177
+ return np.repeat(p.astype(float, copy=False), n, axis=1)
178
+
179
+ if p.shape == target_shape:
180
+ return p.astype(float, copy=False)
181
+
182
+ raise ValueError(
183
+ f"[{self.name}] {name} has incompatible shape {p.shape} for input shape {target_shape}. "
184
+ f"Allowed: scalar (1,1), vector (m,1), or matrix (m,n)."
185
+ )
186
+
187
+ # ------------------------------------------------------------------
188
+ def _resolve_for_input(self, u: np.ndarray) -> None:
189
+ """
190
+ Resolve (broadcast) slopes and initial_output to match the current input shape.
191
+ Done once. After that, input shape is fixed.
192
+ """
193
+ if u.ndim != 2:
194
+ raise ValueError(
195
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
196
+ )
197
+
198
+ if self._resolved_shape is None:
199
+ self._resolved_shape = u.shape
200
+ self.rising_slope = self._broadcast_param(self.rising_raw, u.shape, "rising_slope")
201
+ self.falling_slope = self._broadcast_param(self.falling_raw, u.shape, "falling_slope")
202
+
203
+ # Re-check signs after broadcasting (useful if vector/matrix provided)
204
+ if np.any(self.rising_slope < 0):
205
+ raise ValueError(f"[{self.name}] rising_slope must be >= 0.")
206
+ if np.any(self.falling_slope > 0):
207
+ raise ValueError(f"[{self.name}] falling_slope must be <= 0.")
208
+ return
209
+
210
+ if u.shape != self._resolved_shape:
211
+ raise ValueError(
212
+ f"[{self.name}] Input 'in' shape changed after initialization: "
213
+ f"expected {self._resolved_shape}, got {u.shape}."
214
+ )
@@ -0,0 +1,178 @@
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
+
24
+ from pySimBlocks.core.block import Block
25
+
26
+
27
+ class Saturation(Block):
28
+ """
29
+ Discrete-time saturation operator.
30
+
31
+ Summary:
32
+ Applies element-wise saturation to the input signal by enforcing
33
+ lower and upper bounds.
34
+
35
+ Parameters:
36
+ u_min : scalar or array-like, optional
37
+ Lower saturation bound.
38
+ Accepted: scalar -> (1,1), 1D -> (m,1), 2D -> (m,n).
39
+ Broadcasting rules are limited and explicit (see Notes).
40
+ u_max : scalar or array-like, optional
41
+ Upper saturation bound.
42
+ sample_time : float, optional
43
+ Block execution period.
44
+
45
+ Inputs:
46
+ in : array (m,n)
47
+ Input signal (must be 2D).
48
+
49
+ Outputs:
50
+ out : array (m,n)
51
+ Saturated output signal.
52
+
53
+ Notes:
54
+ - Stateless block.
55
+ - Direct feedthrough.
56
+ - Input must be 2D. No implicit reshape/flatten.
57
+ - Broadcasting rules for bounds (to match input shape (m,n)):
58
+ * scalar (1,1) broadcasts to (m,n)
59
+ * vector (m,1) broadcasts across columns to (m,n)
60
+ * matrix (m,n) must match exactly
61
+ - Any other shape mismatch raises ValueError.
62
+ """
63
+
64
+ direct_feedthrough = True
65
+
66
+ def __init__(
67
+ self,
68
+ name: str,
69
+ u_min: ArrayLike = -np.inf,
70
+ u_max: ArrayLike = np.inf,
71
+ sample_time: float | None = None,
72
+ ):
73
+ super().__init__(name, sample_time)
74
+
75
+ self.inputs["in"] = None
76
+ self.outputs["out"] = None
77
+
78
+ self.u_min_raw = self._to_2d_array("u_min", u_min)
79
+ self.u_max_raw = self._to_2d_array("u_max", u_max)
80
+
81
+ # These will become "resolved" (broadcasted) once we know input shape
82
+ self.u_min = None
83
+ self.u_max = None
84
+ self._resolved_shape: tuple[int, int] | None = None
85
+
86
+
87
+ # --------------------------------------------------------------------------
88
+ # Public methods
89
+ # --------------------------------------------------------------------------
90
+ def initialize(self, t0: float) -> None:
91
+ u = self.inputs["in"]
92
+ if u is None:
93
+ raise RuntimeError(f"[{self.name}] Input 'in' is None at initialization.")
94
+
95
+ u = np.asarray(u, dtype=float)
96
+ if u.ndim != 2:
97
+ raise ValueError(
98
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
99
+ )
100
+
101
+ self._resolve_bounds_for_input(u)
102
+ self.outputs["out"] = np.clip(u, self.u_min, self.u_max)
103
+
104
+ # ------------------------------------------------------------------
105
+ def output_update(self, t: float, dt: float) -> None:
106
+ u = self.inputs["in"]
107
+ if u is None:
108
+ raise RuntimeError(f"[{self.name}] Input 'in' is None.")
109
+
110
+ u = np.asarray(u, dtype=float)
111
+ if u.ndim != 2:
112
+ raise ValueError(
113
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
114
+ )
115
+
116
+ self._resolve_bounds_for_input(u)
117
+ self.outputs["out"] = np.clip(u, self.u_min, self.u_max)
118
+
119
+ # ------------------------------------------------------------------
120
+ def state_update(self, t: float, dt: float) -> None:
121
+ return # stateless
122
+
123
+
124
+ # --------------------------------------------------------------------------
125
+ # Private methods
126
+ # --------------------------------------------------------------------------
127
+ def _resolve_bounds_for_input(self, u: np.ndarray) -> None:
128
+ """
129
+ Resolve (broadcast) u_min/u_max to match the current input shape.
130
+ Resolution is done once (first time input is available). After that,
131
+ input shape is expected to remain constant.
132
+ """
133
+ if u.ndim != 2:
134
+ raise ValueError(
135
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
136
+ )
137
+
138
+ if self._resolved_shape is None:
139
+ self._resolved_shape = u.shape
140
+
141
+ self.u_min = self._broadcast_bound(self.u_min_raw, u.shape, "u_min")
142
+ self.u_max = self._broadcast_bound(self.u_max_raw, u.shape, "u_max")
143
+
144
+ if np.any(self.u_min > self.u_max):
145
+ raise ValueError(f"[{self.name}] u_min must be <= u_max for all components.")
146
+ return
147
+
148
+ # Already resolved => enforce constant input shape
149
+ if u.shape != self._resolved_shape:
150
+ raise ValueError(
151
+ f"[{self.name}] Input 'in' shape changed after bounds were resolved: "
152
+ f"expected {self._resolved_shape}, got {u.shape}."
153
+ )
154
+
155
+ def _broadcast_bound(self, b: np.ndarray, target_shape: tuple[int, int], name: str) -> np.ndarray:
156
+ """
157
+ Broadcast bound b to target_shape using explicit rules.
158
+ """
159
+ m, n = target_shape
160
+
161
+ # scalar -> full matrix
162
+ if self._is_scalar_2d(b):
163
+ return np.full(target_shape, float(b[0, 0]), dtype=float)
164
+
165
+ # vector (m,1) -> broadcast across columns
166
+ if b.ndim == 2 and b.shape[1] == 1 and b.shape[0] == m:
167
+ if n == 1:
168
+ return b.astype(float, copy=False)
169
+ return np.repeat(b.astype(float, copy=False), n, axis=1)
170
+
171
+ # matrix -> must match exactly
172
+ if b.shape == target_shape:
173
+ return b.astype(float, copy=False)
174
+
175
+ raise ValueError(
176
+ f"[{self.name}] {name} has incompatible shape {b.shape} for input shape {target_shape}. "
177
+ f"Allowed: scalar (1,1), vector (m,1), or matrix (m,n)."
178
+ )
@@ -0,0 +1,188 @@
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
+
23
+ from pySimBlocks.core.block import Block
24
+
25
+
26
+ class Sum(Block):
27
+ """
28
+ Multi-input summation block.
29
+
30
+ Summary:
31
+ Computes an element-wise sum/subtraction of multiple input signals.
32
+
33
+ Parameters:
34
+ signs : str
35
+ Sequence of '+' and '-' defining the sign of each input (e.g. '++-', '+-').
36
+ If None, defaults to '++' (two inputs).
37
+ sample_time : float, optional
38
+ Block execution period.
39
+
40
+ Inputs:
41
+ in1, in2, ..., inN : array (m,n)
42
+ Input signals (must be 2D).
43
+ Scalar (1,1) inputs can be broadcast to the common target shape.
44
+
45
+ Outputs:
46
+ out : array (m,n)
47
+ Element-wise signed sum.
48
+
49
+ Notes:
50
+ - Direct feedthrough.
51
+ - Stateless.
52
+ - Shape policy:
53
+ * all inputs must be 2D
54
+ * all non-scalar inputs must share exactly the same shape
55
+ * scalar (1,1) inputs can be broadcast to that shape
56
+ * no other broadcasting is allowed
57
+ """
58
+
59
+ direct_feedthrough = True
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ signs: str | None = None,
65
+ sample_time: float | None = None,
66
+ ):
67
+ super().__init__(name, sample_time)
68
+
69
+ if signs is None:
70
+ signs = "++"
71
+
72
+ if not isinstance(signs, str):
73
+ raise TypeError(f"[{self.name}] 'signs' must be a str.")
74
+
75
+ if len(signs) == 0:
76
+ raise ValueError(f"[{self.name}] 'signs' must not be empty.")
77
+
78
+ if any(s not in ("+", "-") for s in signs):
79
+ raise ValueError(f"[{self.name}] 'signs' must contain only '+' or '-'.")
80
+
81
+ self.signs = [1.0 if s == "+" else -1.0 for s in signs]
82
+ self.num_inputs = len(self.signs)
83
+
84
+ for i in range(self.num_inputs):
85
+ self.inputs[f"in{i+1}"] = None
86
+
87
+ self.outputs["out"] = None
88
+
89
+
90
+ # --------------------------------------------------------------------------
91
+ # Public methods
92
+ # --------------------------------------------------------------------------
93
+ def initialize(self, t0: float) -> None:
94
+ """
95
+ If all inputs are already available, compute output.
96
+ Otherwise output stays None until first output_update().
97
+ """
98
+ if any(self.inputs[f"in{i+1}"] is None for i in range(self.num_inputs)):
99
+ self.outputs["out"] = None
100
+ return
101
+
102
+ self.outputs["out"] = self._compute_output()
103
+
104
+ # ------------------------------------------------------------------
105
+ def output_update(self, t: float, dt: float) -> None:
106
+ # Validate presence + 2D constraint
107
+ arrays = []
108
+ for i in range(self.num_inputs):
109
+ key = f"in{i+1}"
110
+ u = self.inputs[key]
111
+ if u is None:
112
+ raise RuntimeError(f"[{self.name}] Input '{key}' is not connected or not set.")
113
+
114
+ a = np.asarray(u, dtype=float)
115
+ if a.ndim != 2:
116
+ raise ValueError(
117
+ f"[{self.name}] Input '{key}' must be a 2D array. Got ndim={a.ndim} with shape {a.shape}."
118
+ )
119
+ arrays.append(a)
120
+
121
+ self.outputs["out"] = self._compute_output(prevalidated_arrays=arrays)
122
+
123
+ # ------------------------------------------------------------------
124
+ def state_update(self, t: float, dt: float) -> None:
125
+ return # stateless
126
+
127
+
128
+ # --------------------------------------------------------------------------
129
+ # Private methods
130
+ # --------------------------------------------------------------------------
131
+ def _resolve_common_shape(self, arrays: list[np.ndarray]) -> tuple[int, int]:
132
+ """
133
+ Determine target shape among inputs.
134
+
135
+ - Scalars (1,1) are broadcastable
136
+ - Any non-scalar fixes the target shape
137
+ - If multiple non-scalars exist, they must all match exactly
138
+ - If all scalars => target is (1,1)
139
+ """
140
+ non_scalar_shapes = {a.shape for a in arrays if not self._is_scalar_2d(a)}
141
+
142
+ if len(non_scalar_shapes) == 0:
143
+ return (1, 1)
144
+
145
+ if len(non_scalar_shapes) == 1:
146
+ return next(iter(non_scalar_shapes))
147
+
148
+ raise ValueError(
149
+ f"[{self.name}] Inconsistent input shapes for Sum: "
150
+ f"{[a.shape for a in arrays]}. All non-scalar inputs must have the same shape."
151
+ )
152
+
153
+ # ------------------------------------------------------------------
154
+ def _broadcast_scalar_only(self, arr: np.ndarray, target_shape: tuple[int, int], input_name: str) -> np.ndarray:
155
+ """
156
+ Broadcast only scalar (1,1) to target_shape.
157
+ Non-scalar must match target_shape exactly.
158
+ """
159
+ if self._is_scalar_2d(arr):
160
+ if target_shape == (1, 1):
161
+ return arr.astype(float, copy=False)
162
+ return np.full(target_shape, float(arr[0, 0]), dtype=float)
163
+
164
+ if arr.shape != target_shape:
165
+ raise ValueError(
166
+ f"[{self.name}] Input '{input_name}' shape {arr.shape} is incompatible with target shape {target_shape}. "
167
+ f"Only scalar (1,1) inputs can be broadcast."
168
+ )
169
+ return arr.astype(float, copy=False)
170
+
171
+ # ------------------------------------------------------------------
172
+ def _compute_output(self, prevalidated_arrays: list[np.ndarray] | None = None) -> np.ndarray:
173
+ """
174
+ Compute signed element-wise sum with strict scalar-only broadcast.
175
+ """
176
+ if prevalidated_arrays is None:
177
+ arrays = [np.asarray(self.inputs[f"in{i+1}"], dtype=float) for i in range(self.num_inputs)]
178
+ else:
179
+ arrays = prevalidated_arrays
180
+
181
+ target_shape = self._resolve_common_shape(arrays)
182
+
183
+ total = np.zeros(target_shape, dtype=float)
184
+ for i, (s, a) in enumerate(zip(self.signs, arrays), start=1):
185
+ a2 = self._broadcast_scalar_only(a, target_shape, input_name=f"in{i}")
186
+ total += s * a2
187
+
188
+ return total