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,237 @@
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 DiscreteDerivator(Block):
28
+ """
29
+ Discrete-time differentiator block.
30
+
31
+ Summary:
32
+ Estimates the discrete-time derivative of the input signal using a
33
+ backward finite difference:
34
+ y[k] = (u[k] - u[k-1]) / dt
35
+
36
+ Parameters:
37
+ initial_output : scalar or array-like, optional
38
+ Output used at the first execution step.
39
+ If provided, it also FIXES the signal shape permanently.
40
+ sample_time : float, optional
41
+ Block execution period.
42
+
43
+ Inputs:
44
+ in : array (m,n)
45
+ Input signal (must be 2D).
46
+
47
+ Outputs:
48
+ out : array (m,n)
49
+ Estimated discrete-time derivative.
50
+
51
+ Notes:
52
+ - Stateful block.
53
+ - Direct feedthrough.
54
+ - Shape is frozen as soon as known (initial_output or first input).
55
+ - No implicit vector reshape; matrices are supported.
56
+ - Policy:
57
+ - Never propagate None: output is always at least (1,1) zeros.
58
+ - Shape is unresolved while only scalar placeholder (1,1) is seen and no
59
+ explicit initial_output is provided.
60
+ - If initial_output is provided -> shape is frozen immediately (including (1,1)).
61
+ - If no initial_output -> shape is frozen as soon as a non-scalar input arrives.
62
+ - Once shape is frozen -> any non-scalar mismatch raises ValueError.
63
+ - If shape frozen to (m,n) and input is scalar (1,1) -> broadcast scalar to (m,n).
64
+ - When shape is frozen from a first non-scalar input, u_prev is initialized to
65
+ that input to avoid bogus derivative spikes.
66
+ """
67
+
68
+ direct_feedthrough = True
69
+
70
+ def __init__(
71
+ self,
72
+ name: str,
73
+ initial_output: ArrayLike | None = None,
74
+ sample_time: float | None = None,
75
+ ):
76
+ super().__init__(name, sample_time)
77
+
78
+ self.inputs["in"] = None
79
+ self.outputs["out"] = None
80
+
81
+ self.state["u_prev"] = None
82
+ self.next_state["u_prev"] = None
83
+
84
+ self._resolved_shape: tuple[int, int] | None = None
85
+ self._first_output = True
86
+
87
+ # Never None: placeholder output at minimum
88
+ self._placeholder = np.zeros((1, 1), dtype=float)
89
+
90
+ self._initial_output_raw: np.ndarray | None = None
91
+ if initial_output is not None:
92
+ y0 = self._to_2d_array("initial_output", initial_output).astype(float)
93
+ self._initial_output_raw = y0.copy()
94
+
95
+ # initial_output freezes shape (including (1,1))
96
+ self._resolved_shape = y0.shape
97
+ self.outputs["out"] = y0.copy()
98
+ else:
99
+ self.outputs["out"] = self._placeholder.copy()
100
+
101
+
102
+ # --------------------------------------------------------------------------
103
+ # Public methods
104
+ # --------------------------------------------------------------------------
105
+ def initialize(self, t0: float) -> None:
106
+ """
107
+ Never propagate None:
108
+ - output already set (initial_output or placeholder (1,1)).
109
+ - if input exists, we can set u_prev consistently.
110
+ - if input missing, keep u_prev=None (or already set if frozen on first non-scalar later).
111
+ """
112
+ u = self.inputs["in"]
113
+
114
+ if u is None:
115
+ # Keep output as-is (initial_output or placeholder)
116
+ self.state["u_prev"] = None
117
+ self.next_state["u_prev"] = None
118
+ self._first_output = True
119
+ return
120
+
121
+ u_arr = self._normalize_input(u)
122
+
123
+ # If initial_output froze shape, _normalize_input enforces it.
124
+ # Store u_prev to support derivative at next step.
125
+ self.state["u_prev"] = u_arr.copy()
126
+ self.next_state["u_prev"] = u_arr.copy()
127
+ self._first_output = True
128
+
129
+ # -------------------------------------------------------
130
+ def output_update(self, t: float, dt: float) -> None:
131
+ """
132
+ First call policy:
133
+ - If initial_output provided: keep it for first output_update.
134
+ - Else: keep zero output for first call.
135
+
136
+ Afterwards:
137
+ y = (u - u_prev) / dt
138
+ """
139
+ u_arr = self._normalize_input(self.inputs["in"])
140
+
141
+ if self._first_output:
142
+ self._first_output = False
143
+ # output already set (initial_output or placeholder); ensure shape if frozen
144
+ if self._resolved_shape is not None and self.outputs["out"] is not None:
145
+ y = np.asarray(self.outputs["out"], dtype=float)
146
+ if y.shape == (1, 1) and self._resolved_shape != (1, 1):
147
+ self.outputs["out"] = np.full(self._resolved_shape, float(y[0, 0]), dtype=float)
148
+ return
149
+
150
+ u_prev = self.state["u_prev"]
151
+ if u_prev is None:
152
+ # No previous value -> define derivative as zero (same shape as u)
153
+ self.outputs["out"] = np.zeros_like(u_arr)
154
+ return
155
+
156
+ # If shape frozen and u_prev is scalar, broadcast it
157
+ u_prev_arr = np.asarray(u_prev, dtype=float)
158
+ if self._resolved_shape is not None and u_prev_arr.shape == (1, 1) and self._resolved_shape != (1, 1):
159
+ u_prev_arr = np.full(self._resolved_shape, float(u_prev_arr[0, 0]), dtype=float)
160
+
161
+ if u_prev_arr.shape != u_arr.shape:
162
+ raise ValueError(
163
+ f"[{self.name}] Previous input shape mismatch: u_prev={u_prev_arr.shape}, u={u_arr.shape}."
164
+ )
165
+
166
+ self.outputs["out"] = (u_arr - u_prev_arr) / dt
167
+
168
+ # -------------------------------------------------------
169
+ def state_update(self, t: float, dt: float) -> None:
170
+ u_arr = self._normalize_input(self.inputs["in"])
171
+ self.next_state["u_prev"] = u_arr.copy()
172
+
173
+
174
+ # --------------------------------------------------------------------------
175
+ # Private methods
176
+ # --------------------------------------------------------------------------
177
+ def _maybe_freeze_shape_from(self, u: np.ndarray) -> None:
178
+ """
179
+ Freeze shape if:
180
+ - no initial_output has already frozen it
181
+ - shape unresolved
182
+ - u is non-scalar (shape != (1,1))
183
+ """
184
+ if u.ndim != 2:
185
+ raise ValueError(
186
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
187
+ )
188
+
189
+ # If shape is already frozen (by initial_output), nothing to do
190
+ if self._resolved_shape is not None:
191
+ return
192
+
193
+ # Only freeze when a non-scalar appears
194
+ if u.shape != (1, 1):
195
+ self._resolved_shape = u.shape
196
+
197
+ # Upgrade current output placeholder to correct shape (keep current scalar value)
198
+ y = np.asarray(self.outputs["out"], dtype=float)
199
+ if y.shape == (1, 1):
200
+ scalar = float(y[0, 0])
201
+ self.outputs["out"] = np.full(self._resolved_shape, scalar, dtype=float)
202
+
203
+ # Initialize u_prev to current u to avoid a derivative spike
204
+ self.state["u_prev"] = u.copy()
205
+ self.next_state["u_prev"] = u.copy()
206
+
207
+ # -------------------------------------------------------
208
+ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray:
209
+ """
210
+ Normalize u to 2D, apply freezing rule and scalar broadcasting.
211
+ If u is None: return zeros of resolved shape if known, else (1,1) zeros.
212
+ """
213
+ if u is None:
214
+ if self._resolved_shape is not None:
215
+ return np.zeros(self._resolved_shape, dtype=float)
216
+ return self._placeholder.copy()
217
+
218
+ u_arr = np.asarray(u, dtype=float)
219
+ if u_arr.ndim != 2:
220
+ raise ValueError(
221
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u_arr.ndim} with shape {u_arr.shape}."
222
+ )
223
+
224
+ # If shape not frozen yet and u is non-scalar -> freeze now
225
+ self._maybe_freeze_shape_from(u_arr)
226
+
227
+ # If shape is frozen: enforce or broadcast
228
+ if self._resolved_shape is not None:
229
+ if u_arr.shape == (1, 1) and self._resolved_shape != (1, 1):
230
+ return np.full(self._resolved_shape, float(u_arr[0, 0]), dtype=float)
231
+
232
+ if u_arr.shape != self._resolved_shape:
233
+ raise ValueError(
234
+ f"[{self.name}] Input 'in' shape changed: expected {self._resolved_shape}, got {u_arr.shape}."
235
+ )
236
+
237
+ return u_arr
@@ -0,0 +1,265 @@
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 DiscreteIntegrator(Block):
28
+ """
29
+ Discrete-time integrator block.
30
+
31
+ Summary:
32
+ Integrates an input signal over time using a discrete-time numerical
33
+ integration scheme.
34
+
35
+ Parameters:
36
+ initial_state : scalar or array-like, optional
37
+ Initial value of the integrated state. If provided, it FIXES the signal shape.
38
+ method : str
39
+ Numerical integration method: "euler forward" or "euler backward".
40
+ sample_time : float, optional
41
+ Block execution period.
42
+
43
+ Inputs:
44
+ in : array (m,n)
45
+ Signal to integrate (must be 2D).
46
+
47
+ Outputs:
48
+ out : array (m,n)
49
+ Integrated signal.
50
+
51
+ Notes:
52
+ - Stateful block.
53
+ - Direct feedthrough depends on method:
54
+ * euler forward -> False
55
+ * euler backward -> True
56
+ - Shape is frozen as soon as known (initial_state or first input).
57
+ - No implicit vector reshape; matrices are supported.
58
+ - Policy:
59
+ + Never propagate None: output is always a 2D array (at least (1,1)).
60
+ + Shape is NOT frozen while we are in placeholder shape (1,1) and no explicit
61
+ non-scalar initial_state was given.
62
+ + As soon as a non-scalar input appears (shape != (1,1)), the shape becomes frozen.
63
+ + If initial_state is provided and non-scalar, it freezes the shape immediately.
64
+ If initial_state is scalar (1,1), it is treated as a placeholder: can be upgraded
65
+ once a non-scalar input appears.
66
+ + After shape is frozen, any non-scalar input shape mismatch raises ValueError.
67
+ + If shape is frozen to (m,n), scalar input (1,1) is broadcast to (m,n).
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ name: str,
73
+ initial_state: ArrayLike | None = None,
74
+ method: str = "euler forward",
75
+ sample_time: float | None = None,
76
+ ):
77
+ super().__init__(name, sample_time)
78
+
79
+ self.method = method.lower()
80
+ if self.method not in ("euler forward", "euler backward"):
81
+ raise ValueError(
82
+ f"[{self.name}] Unsupported method '{method}'. "
83
+ f"Allowed: 'euler forward', 'euler backward'."
84
+ )
85
+
86
+ # direct feedthrough policy
87
+ self.direct_feedthrough = (self.method == "euler backward")
88
+
89
+ # ports
90
+ self.inputs["in"] = None
91
+ self.outputs["out"] = None
92
+
93
+ # shape policy
94
+ self._resolved_shape: tuple[int, int] | None = None
95
+
96
+ # state
97
+ self.state["x"] = None
98
+ self.next_state["x"] = None
99
+
100
+ # Placeholder initialization (never None)
101
+ self._placeholder = np.zeros((1, 1), dtype=float)
102
+
103
+ self._initial_state_raw: np.ndarray | None = None
104
+ if initial_state is not None:
105
+ x0 = self._to_2d_array("initial_state", initial_state).astype(float)
106
+ self._initial_state_raw = x0.copy()
107
+
108
+ # If non-scalar: freeze immediately. If scalar (1,1): keep unfrozen placeholder semantics.
109
+ if x0.shape != (1, 1):
110
+ self._resolved_shape = x0.shape
111
+
112
+ self.state["x"] = x0.copy()
113
+ self.next_state["x"] = x0.copy()
114
+ self.outputs["out"] = x0.copy()
115
+ else:
116
+ self.state["x"] = self._placeholder.copy()
117
+ self.next_state["x"] = self._placeholder.copy()
118
+ self.outputs["out"] = self._placeholder.copy()
119
+
120
+
121
+ # --------------------------------------------------------------------------
122
+ # Public methods
123
+ # --------------------------------------------------------------------------
124
+ def initialize(self, t0: float) -> None:
125
+ # Never propagate None: guarantee placeholders even when no initial_state.
126
+ if self._initial_state_raw is not None:
127
+ x0 = self._initial_state_raw.copy()
128
+
129
+ # If initial_state is non-scalar, freeze is already set in __init__.
130
+ # If scalar, keep unresolved unless later a non-scalar input appears.
131
+ self.state["x"] = x0.copy()
132
+ self.next_state["x"] = x0.copy()
133
+
134
+ # output at init:
135
+ # forward -> y=x
136
+ # backward -> y=x + dt*u, but u may be unknown at init; we take u=0 (consistent with "no None")
137
+ if self.method == "euler forward":
138
+ self.outputs["out"] = x0.copy()
139
+ else:
140
+ self.outputs["out"] = x0.copy()
141
+
142
+ else:
143
+ self.state["x"] = self._placeholder.copy()
144
+ self.next_state["x"] = self._placeholder.copy()
145
+ self.outputs["out"] = self._placeholder.copy()
146
+
147
+ # ------------------------------------------------------------------
148
+ def output_update(self, t: float, dt: float) -> None:
149
+ x = self._normalize_state()
150
+
151
+ if self.method == "euler forward":
152
+ self.outputs["out"] = x.copy()
153
+ return
154
+
155
+ # euler backward: y = x + dt*u
156
+ u = self._normalize_input(self.inputs["in"])
157
+ self.outputs["out"] = x + dt * u
158
+
159
+ # ------------------------------------------------------------------
160
+ def state_update(self, t: float, dt: float) -> None:
161
+ # Even if input is not available due to execution order, do not crash:
162
+ # treat missing as zeros (same idea: "0 if not defined").
163
+ u = self._normalize_input(self.inputs["in"])
164
+
165
+ x = self._normalize_state()
166
+
167
+ # ensure x matches u when shape resolved by u
168
+ if self._resolved_shape is not None:
169
+ if x.shape != u.shape:
170
+ raise ValueError(
171
+ f"[{self.name}] Shape mismatch between state and input: x={x.shape}, u={u.shape}."
172
+ )
173
+
174
+ self.next_state["x"] = x + dt * u
175
+
176
+
177
+ # --------------------------------------------------------------------------
178
+ # Private methods
179
+ # --------------------------------------------------------------------------
180
+ def _maybe_freeze_shape_from(self, u: np.ndarray) -> None:
181
+ """
182
+ Freeze shape if:
183
+ - shape not resolved yet
184
+ - and u is non-scalar (shape != (1,1))
185
+ """
186
+ if u.ndim != 2:
187
+ raise ValueError(
188
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u.ndim} with shape {u.shape}."
189
+ )
190
+
191
+ if self._resolved_shape is None and u.shape != (1, 1):
192
+ self._resolved_shape = u.shape
193
+
194
+ # Upgrade state/output from placeholder scalar -> matrix if needed
195
+ if self.state["x"] is None:
196
+ self.state["x"] = np.zeros(self._resolved_shape, dtype=float)
197
+ else:
198
+ x = np.asarray(self.state["x"], dtype=float)
199
+ if x.shape == (1, 1):
200
+ scalar = float(x[0, 0])
201
+ self.state["x"] = np.full(self._resolved_shape, scalar, dtype=float)
202
+
203
+ # keep next_state consistent
204
+ self.next_state["x"] = np.asarray(self.state["x"], dtype=float).copy()
205
+
206
+ y = np.asarray(self.outputs["out"], dtype=float)
207
+ if y.shape == (1, 1):
208
+ scalar = float(y[0, 0])
209
+ self.outputs["out"] = np.full(self._resolved_shape, scalar, dtype=float)
210
+
211
+ # ------------------------------------------------------------------
212
+ def _normalize_input(self, u: ArrayLike | None) -> np.ndarray:
213
+ """
214
+ Normalize input to 2D array, apply shape policy and scalar broadcasting.
215
+
216
+ If u is None:
217
+ - return zeros of resolved shape if known,
218
+ - else return (1,1) zeros (placeholder), without freezing.
219
+ """
220
+ if u is None:
221
+ if self._resolved_shape is not None:
222
+ return np.zeros(self._resolved_shape, dtype=float)
223
+ return self._placeholder.copy()
224
+
225
+ u_arr = np.asarray(u, dtype=float)
226
+ if u_arr.ndim != 2:
227
+ raise ValueError(
228
+ f"[{self.name}] Input 'in' must be a 2D array. Got ndim={u_arr.ndim} with shape {u_arr.shape}."
229
+ )
230
+
231
+ # Potentially freeze shape when a non-scalar appears
232
+ self._maybe_freeze_shape_from(u_arr)
233
+
234
+ # If shape is frozen and input is scalar -> broadcast
235
+ if self._resolved_shape is not None:
236
+ if u_arr.shape == (1, 1) and self._resolved_shape != (1, 1):
237
+ return np.full(self._resolved_shape, float(u_arr[0, 0]), dtype=float)
238
+
239
+ if u_arr.shape != self._resolved_shape:
240
+ raise ValueError(
241
+ f"[{self.name}] Input 'in' shape changed: expected {self._resolved_shape}, got {u_arr.shape}."
242
+ )
243
+
244
+ return u_arr
245
+
246
+ # ------------------------------------------------------------------
247
+ def _normalize_state(self) -> np.ndarray:
248
+ """
249
+ Ensure state exists and matches resolved shape.
250
+ """
251
+ x = np.asarray(self.state["x"], dtype=float)
252
+
253
+ # If shape resolved and x is scalar placeholder -> broadcast
254
+ if self._resolved_shape is not None and self._resolved_shape != (1, 1):
255
+ if x.shape == (1, 1):
256
+ scalar = float(x[0, 0])
257
+ x = np.full(self._resolved_shape, scalar, dtype=float)
258
+ self.state["x"] = x.copy()
259
+
260
+ if x.shape != self._resolved_shape:
261
+ raise ValueError(
262
+ f"[{self.name}] State shape mismatch: expected {self._resolved_shape}, got {x.shape}."
263
+ )
264
+
265
+ return x