pySimBlocks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/advanced/hardware/block_diagram.py +162 -0
- examples/advanced/hardware/camera.py +93 -0
- examples/advanced/hardware/gui_emio.py +231 -0
- examples/advanced/hardware/lmi.py +69 -0
- examples/advanced/hardware/main.py +57 -0
- examples/advanced/hardware/parameters.py +54 -0
- examples/advanced/qp_problem/params.py +10 -0
- examples/advanced/qp_problem/run.py +14 -0
- examples/advanced/sofa/gui/finger/Finger.py +108 -0
- examples/advanced/sofa/gui/finger/FingerController.py +53 -0
- examples/advanced/sofa/gui/finger/parameters_auto.py +22 -0
- examples/advanced/sofa/gui/sofa_plant/run.py +14 -0
- examples/advanced/sofa/python/Finger.py +107 -0
- examples/advanced/sofa/python/FingerController.py +48 -0
- examples/advanced/sofa/python/closedloop.py +64 -0
- examples/advanced/sofa/python/openloop.py +58 -0
- examples/basics/algebraic_function/g.py +7 -0
- examples/basics/algebraic_function/run.py +14 -0
- examples/basics/dc_motor/gui/run.py +14 -0
- examples/basics/dc_motor/multiple_order.py +91 -0
- examples/basics/dc_motor/openloop.py +62 -0
- examples/basics/dc_motor/pi.py +99 -0
- examples/basics/dc_motor/pid.py +118 -0
- examples/basics/dc_motor/pid_block.py +82 -0
- examples/basics/dc_motor/pid_block_vs_manual.py +143 -0
- examples/basics/external/parameters.py +4 -0
- examples/basics/external/run.py +14 -0
- examples/basics/file_sources/temp.py +16 -0
- examples/basics/observer.py +76 -0
- examples/basics/state_feedback/gui/parameters.py +9 -0
- examples/basics/state_feedback/gui/run.py +14 -0
- examples/basics/state_feedback/state_feedback.py +102 -0
- examples/basics/state_space/linear/gui/run.py +14 -0
- examples/basics/state_space/linear/ss_block.py +89 -0
- examples/basics/state_space/linear/ss_manual.py +105 -0
- examples/basics/state_space/non_linear/nlss.py +47 -0
- examples/basics/state_space/non_linear/run.py +14 -0
- examples/basics/state_space/polytope/params.py +39 -0
- examples/basics/state_space/polytope/weights.py +12 -0
- examples/comparison/simulink/convert.py +29 -0
- examples/comparison/simulink/openloop.py +127 -0
- examples/comparison/simulink/pi.py +148 -0
- examples/comparison/simulink/pid.py +154 -0
- examples/comparison/simulink/pid_backward.py +155 -0
- examples/quick_start/filter.py +35 -0
- examples/quick_start/gui/run.py +14 -0
- examples/tutorials/tutorial_1_python/main.py +99 -0
- examples/tutorials/tutorial_3_sofa/finger/Finger.py +120 -0
- examples/tutorials/tutorial_3_sofa/finger/FingerController.py +37 -0
- pySimBlocks/__init__.py +35 -0
- pySimBlocks/blocks/__init__.py +68 -0
- pySimBlocks/blocks/controllers/__init__.py +27 -0
- pySimBlocks/blocks/controllers/pid.py +250 -0
- pySimBlocks/blocks/controllers/state_feedback.py +141 -0
- pySimBlocks/blocks/interfaces/__init__.py +27 -0
- pySimBlocks/blocks/interfaces/external_input.py +107 -0
- pySimBlocks/blocks/interfaces/external_output.py +113 -0
- pySimBlocks/blocks/observers/__init__.py +23 -0
- pySimBlocks/blocks/observers/luenberger.py +175 -0
- pySimBlocks/blocks/operators/__init__.py +47 -0
- pySimBlocks/blocks/operators/algebraic_function.py +258 -0
- pySimBlocks/blocks/operators/dead_zone.py +186 -0
- pySimBlocks/blocks/operators/delay.py +254 -0
- pySimBlocks/blocks/operators/demux.py +123 -0
- pySimBlocks/blocks/operators/discrete_derivator.py +237 -0
- pySimBlocks/blocks/operators/discrete_integrator.py +265 -0
- pySimBlocks/blocks/operators/gain.py +294 -0
- pySimBlocks/blocks/operators/mux.py +131 -0
- pySimBlocks/blocks/operators/product.py +194 -0
- pySimBlocks/blocks/operators/rate_limiter.py +214 -0
- pySimBlocks/blocks/operators/saturation.py +178 -0
- pySimBlocks/blocks/operators/sum.py +188 -0
- pySimBlocks/blocks/operators/zero_order_hold.py +141 -0
- pySimBlocks/blocks/optimizers/__init__.py +23 -0
- pySimBlocks/blocks/optimizers/quadratic_program.py +326 -0
- pySimBlocks/blocks/sources/__init__.py +37 -0
- pySimBlocks/blocks/sources/chirp.py +168 -0
- pySimBlocks/blocks/sources/constant.py +82 -0
- pySimBlocks/blocks/sources/file_source.py +294 -0
- pySimBlocks/blocks/sources/function_source.py +197 -0
- pySimBlocks/blocks/sources/ramp.py +99 -0
- pySimBlocks/blocks/sources/sinusoidal.py +113 -0
- pySimBlocks/blocks/sources/step.py +138 -0
- pySimBlocks/blocks/sources/white_noise.py +107 -0
- pySimBlocks/blocks/systems/__init__.py +27 -0
- pySimBlocks/blocks/systems/linear_state_space.py +180 -0
- pySimBlocks/blocks/systems/non_linear_state_space.py +309 -0
- pySimBlocks/blocks/systems/polytopic_state_space.py +193 -0
- pySimBlocks/blocks/systems/sofa/__init__.py +30 -0
- pySimBlocks/blocks/systems/sofa/sofa_controller.py +472 -0
- pySimBlocks/blocks/systems/sofa/sofa_exchange_i_o.py +120 -0
- pySimBlocks/blocks/systems/sofa/sofa_plant.py +306 -0
- pySimBlocks/cli.py +127 -0
- pySimBlocks/core/__init__.py +32 -0
- pySimBlocks/core/block.py +167 -0
- pySimBlocks/core/block_source.py +94 -0
- pySimBlocks/core/config.py +90 -0
- pySimBlocks/core/fixed_time_manager.py +46 -0
- pySimBlocks/core/model.py +250 -0
- pySimBlocks/core/scheduler.py +30 -0
- pySimBlocks/core/simulator.py +329 -0
- pySimBlocks/core/task.py +73 -0
- pySimBlocks/docs/blocks/controllers/pid.md +78 -0
- pySimBlocks/docs/blocks/controllers/state_feedback.md +60 -0
- pySimBlocks/docs/blocks/interfaces/external_input.md +39 -0
- pySimBlocks/docs/blocks/interfaces/external_output.md +39 -0
- pySimBlocks/docs/blocks/observers/luenberger.md +70 -0
- pySimBlocks/docs/blocks/operators/algebraic_function.md +76 -0
- pySimBlocks/docs/blocks/operators/dead_zone.md +67 -0
- pySimBlocks/docs/blocks/operators/delay.md +76 -0
- pySimBlocks/docs/blocks/operators/demux.md +64 -0
- pySimBlocks/docs/blocks/operators/discrete_derivator.md +61 -0
- pySimBlocks/docs/blocks/operators/discrete_integrator.md +87 -0
- pySimBlocks/docs/blocks/operators/gain.md +97 -0
- pySimBlocks/docs/blocks/operators/mux.md +64 -0
- pySimBlocks/docs/blocks/operators/product.md +51 -0
- pySimBlocks/docs/blocks/operators/rate_limiter.md +70 -0
- pySimBlocks/docs/blocks/operators/saturation.md +62 -0
- pySimBlocks/docs/blocks/operators/sum.md +49 -0
- pySimBlocks/docs/blocks/operators/zero_order_hold.md +61 -0
- pySimBlocks/docs/blocks/optimizers/quadratic_program.md +98 -0
- pySimBlocks/docs/blocks/sources/chirp.md +102 -0
- pySimBlocks/docs/blocks/sources/constant.md +53 -0
- pySimBlocks/docs/blocks/sources/file_source.md +51 -0
- pySimBlocks/docs/blocks/sources/function_source.md +52 -0
- pySimBlocks/docs/blocks/sources/ramp.md +58 -0
- pySimBlocks/docs/blocks/sources/sinusoidal.md +60 -0
- pySimBlocks/docs/blocks/sources/step.md +63 -0
- pySimBlocks/docs/blocks/sources/white_noise.md +58 -0
- pySimBlocks/docs/blocks/systems/linear_state_space.md +66 -0
- pySimBlocks/docs/blocks/systems/non_linear_state_space.md +90 -0
- pySimBlocks/docs/blocks/systems/polytopic_state_space.md +72 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_exchange_i_o.md +63 -0
- pySimBlocks/docs/blocks/systems/sofa/sofa_plant.md +62 -0
- pySimBlocks/gui/__init__.py +20 -0
- pySimBlocks/gui/addons/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/__init__.py +19 -0
- pySimBlocks/gui/addons/sofa/sofa_dialog.py +190 -0
- pySimBlocks/gui/addons/sofa/sofa_service.py +209 -0
- pySimBlocks/gui/blocks/block_dialog_session.py +47 -0
- pySimBlocks/gui/blocks/block_meta.py +408 -0
- pySimBlocks/gui/blocks/controllers/pid.py +116 -0
- pySimBlocks/gui/blocks/controllers/state_feedback.py +80 -0
- pySimBlocks/gui/blocks/interfaces/external_input.py +64 -0
- pySimBlocks/gui/blocks/interfaces/external_output.py +63 -0
- pySimBlocks/gui/blocks/observers/luenberger.py +108 -0
- pySimBlocks/gui/blocks/operators/algebraic_function.py +232 -0
- pySimBlocks/gui/blocks/operators/dead_zone.py +79 -0
- pySimBlocks/gui/blocks/operators/delay.py +79 -0
- pySimBlocks/gui/blocks/operators/demux.py +98 -0
- pySimBlocks/gui/blocks/operators/discrete_derivator.py +67 -0
- pySimBlocks/gui/blocks/operators/discrete_integrator.py +80 -0
- pySimBlocks/gui/blocks/operators/gain.py +79 -0
- pySimBlocks/gui/blocks/operators/mux.py +100 -0
- pySimBlocks/gui/blocks/operators/product.py +109 -0
- pySimBlocks/gui/blocks/operators/rate_limiter.py +79 -0
- pySimBlocks/gui/blocks/operators/saturation.py +72 -0
- pySimBlocks/gui/blocks/operators/sum.py +91 -0
- pySimBlocks/gui/blocks/operators/zero_order_hold.py +64 -0
- pySimBlocks/gui/blocks/optimizers/quadratic_program.py +128 -0
- pySimBlocks/gui/blocks/parameter_meta.py +34 -0
- pySimBlocks/gui/blocks/port_meta.py +31 -0
- pySimBlocks/gui/blocks/sources/chirp.py +146 -0
- pySimBlocks/gui/blocks/sources/constant.py +63 -0
- pySimBlocks/gui/blocks/sources/file_source.py +196 -0
- pySimBlocks/gui/blocks/sources/function_source.py +202 -0
- pySimBlocks/gui/blocks/sources/ramp.py +73 -0
- pySimBlocks/gui/blocks/sources/sinusoidal.py +81 -0
- pySimBlocks/gui/blocks/sources/step.py +83 -0
- pySimBlocks/gui/blocks/sources/white_noise.py +70 -0
- pySimBlocks/gui/blocks/systems/linear_state_space.py +102 -0
- pySimBlocks/gui/blocks/systems/non_linear_state_space.py +233 -0
- pySimBlocks/gui/blocks/systems/polytopic_state_space.py +109 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_exchange_i_o.py +221 -0
- pySimBlocks/gui/blocks/systems/sofa/sofa_plant.py +220 -0
- pySimBlocks/gui/dialogs/__init__.py +19 -0
- pySimBlocks/gui/dialogs/block_dialog.py +125 -0
- pySimBlocks/gui/dialogs/display_yaml_dialog.py +89 -0
- pySimBlocks/gui/dialogs/help_dialog.py +39 -0
- pySimBlocks/gui/dialogs/plot_dialog.py +230 -0
- pySimBlocks/gui/dialogs/settings/plots.py +187 -0
- pySimBlocks/gui/dialogs/settings/project.py +146 -0
- pySimBlocks/gui/dialogs/settings/simulation.py +137 -0
- pySimBlocks/gui/dialogs/settings_dialog.py +88 -0
- pySimBlocks/gui/dialogs/unsaved_dialog.py +92 -0
- pySimBlocks/gui/editor.py +49 -0
- pySimBlocks/gui/graphics/__init__.py +29 -0
- pySimBlocks/gui/graphics/block_item.py +289 -0
- pySimBlocks/gui/graphics/connection_item.py +266 -0
- pySimBlocks/gui/graphics/port_item.py +153 -0
- pySimBlocks/gui/graphics/theme.py +119 -0
- pySimBlocks/gui/main_window.py +162 -0
- pySimBlocks/gui/models/__init__.py +31 -0
- pySimBlocks/gui/models/block_instance.py +114 -0
- pySimBlocks/gui/models/connection_instance.py +43 -0
- pySimBlocks/gui/models/port_instance.py +52 -0
- pySimBlocks/gui/models/project_simulation_params.py +50 -0
- pySimBlocks/gui/models/project_state.py +114 -0
- pySimBlocks/gui/project_controller.py +302 -0
- pySimBlocks/gui/services/__init__.py +20 -0
- pySimBlocks/gui/services/project_loader.py +381 -0
- pySimBlocks/gui/services/project_saver.py +68 -0
- pySimBlocks/gui/services/simulation_runner.py +66 -0
- pySimBlocks/gui/services/yaml_tools.py +261 -0
- pySimBlocks/gui/widgets/__init__.py +19 -0
- pySimBlocks/gui/widgets/block_list.py +131 -0
- pySimBlocks/gui/widgets/diagram_view.py +312 -0
- pySimBlocks/gui/widgets/toolbar_view.py +161 -0
- pySimBlocks/project/__init__.py +35 -0
- pySimBlocks/project/build_model.py +92 -0
- pySimBlocks/project/generate_run_script.py +109 -0
- pySimBlocks/project/generate_sofa_controller.py +215 -0
- pySimBlocks/project/load_project_config.py +197 -0
- pySimBlocks/project/load_simulation_config.py +166 -0
- pySimBlocks/project/load_simulator.py +45 -0
- pySimBlocks/project/plot_from_config.py +174 -0
- pySimBlocks/project/pySimBlocks_blocks_index.yaml +106 -0
- pySimBlocks/real_time/__init__.py +23 -0
- pySimBlocks/real_time/real_time_runner.py +148 -0
- pySimBlocks/tools/__init__.py +19 -0
- pySimBlocks/tools/blocks_registry.py +141 -0
- pySimBlocks/tools/generate_blocks_index.py +124 -0
- pysimblocks-0.1.0.dist-info/METADATA +180 -0
- pysimblocks-0.1.0.dist-info/RECORD +262 -0
- pysimblocks-0.1.0.dist-info/WHEEL +5 -0
- pysimblocks-0.1.0.dist-info/entry_points.txt +2 -0
- pysimblocks-0.1.0.dist-info/licenses/LICENSE.md +165 -0
- pysimblocks-0.1.0.dist-info/top_level.txt +3 -0
- tests/blocks/controllers/test_pid.py +143 -0
- tests/blocks/controllers/test_state_feedback.py +193 -0
- tests/blocks/observers/test_luenberger.py +225 -0
- tests/blocks/operators/test_algebraic_function.py +230 -0
- tests/blocks/operators/test_dead_zone.py +106 -0
- tests/blocks/operators/test_delay.py +167 -0
- tests/blocks/operators/test_demux.py +111 -0
- tests/blocks/operators/test_discrete_derivator.py +160 -0
- tests/blocks/operators/test_discrete_integrator.py +149 -0
- tests/blocks/operators/test_gain.py +182 -0
- tests/blocks/operators/test_mux.py +101 -0
- tests/blocks/operators/test_product.py +171 -0
- tests/blocks/operators/test_rate_limiter.py +166 -0
- tests/blocks/operators/test_saturation.py +157 -0
- tests/blocks/operators/test_sum.py +183 -0
- tests/blocks/operators/test_zero_order_hold.py +106 -0
- tests/blocks/sources/test_chirp.py +157 -0
- tests/blocks/sources/test_constant.py +55 -0
- tests/blocks/sources/test_file_source.py +185 -0
- tests/blocks/sources/test_function_source.py +135 -0
- tests/blocks/sources/test_ramp.py +110 -0
- tests/blocks/sources/test_sinusoidal.py +113 -0
- tests/blocks/sources/test_step.py +76 -0
- tests/blocks/sources/test_white_noise.py +108 -0
- tests/blocks/systems/test_linear_state_space.py +135 -0
- tests/blocks/systems/test_polytopic_state_space.py +133 -0
- tests/core/test_core_errors_and_determinism.py +82 -0
- tests/core/test_core_multirate_tasks.py +88 -0
- tests/core/test_core_two_phase_and_propagation.py +132 -0
- tests/gui/main_window_open_test.py +162 -0
- tests/gui/test_block_meta_param_cache.py +16 -0
- tests/gui/test_file_source_meta.py +17 -0
- tests/gui/test_function_source_meta.py +28 -0
- tests/regression/test_simulink_dc_motor.py +217 -0
|
@@ -0,0 +1,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
|