rtint 0.0.3__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.
- rtint/_version.py +24 -0
- rtint/rtint_acquisition.py +327 -0
- rtint/rtint_counter.py +202 -0
- rtint/rtint_ethercat.py +127 -0
- rtint/rtint_filter.py +872 -0
- rtint/rtint_generator.py +577 -0
- rtint/rtint_hardware.py +336 -0
- rtint/rtint_lut.py +143 -0
- rtint/rtint_motor.py +191 -0
- rtint/rtint_parameter.py +105 -0
- rtint/rtint_regul.py +310 -0
- rtint/rtint_signal.py +42 -0
- rtint/rtint_trigger.py +107 -0
- rtint/rtint_utils.py +648 -0
- rtint-0.0.3.dist-info/METADATA +74 -0
- rtint-0.0.3.dist-info/RECORD +19 -0
- rtint-0.0.3.dist-info/WHEEL +5 -0
- rtint-0.0.3.dist-info/licenses/LICENSE +238 -0
- rtint-0.0.3.dist-info/top_level.txt +1 -0
rtint/rtint_filter.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
# This file is part of the mechatronic project
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) Beamline Control Unit, ESRF
|
|
4
|
+
# Distributed under the GNU LGPLv3. See LICENSE for more info.
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
REAL-TIME TARGET Signal filters
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.signal import dlti
|
|
12
|
+
from tabulate import tabulate
|
|
13
|
+
|
|
14
|
+
RED = "\033[31m"
|
|
15
|
+
GREEN = "\033[32m"
|
|
16
|
+
RESET = "\033[0m"
|
|
17
|
+
|
|
18
|
+
class RtintHdwFilterController:
|
|
19
|
+
def __init__(self, tg):
|
|
20
|
+
self._tg = tg
|
|
21
|
+
self._system = tg._system
|
|
22
|
+
self._program = tg._program
|
|
23
|
+
self._param_tree = self._program.tree.params
|
|
24
|
+
|
|
25
|
+
self._filters: dict[str, RtintHdwFilter] | None = None
|
|
26
|
+
self._load()
|
|
27
|
+
|
|
28
|
+
def __info__(self, debug=False):
|
|
29
|
+
if self._filters is None:
|
|
30
|
+
return "\n No Filter in the model"
|
|
31
|
+
|
|
32
|
+
if debug:
|
|
33
|
+
lines = [["Name", "Path", "Type", "Parameters"]]
|
|
34
|
+
else:
|
|
35
|
+
lines = [["Name", "Type", "Parameters"]]
|
|
36
|
+
for _filter in self._filters.values():
|
|
37
|
+
if debug:
|
|
38
|
+
lines.append(
|
|
39
|
+
[
|
|
40
|
+
_filter._name,
|
|
41
|
+
_filter._unique_name,
|
|
42
|
+
_filter._type,
|
|
43
|
+
", ".join(parameter for parameter in _filter._filter_params),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
lines.append(
|
|
48
|
+
[
|
|
49
|
+
_filter._name,
|
|
50
|
+
_filter._type,
|
|
51
|
+
", ".join(parameter for parameter in _filter._filter_params),
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
return "\n" + tabulate(lines, headers="firstrow", tablefmt="grid", stralign="left")
|
|
55
|
+
|
|
56
|
+
def _load(self):
|
|
57
|
+
"""Automatically discover all filters present in the model."""
|
|
58
|
+
filters = self._tg._get_all_objects_from_key("bliss_filter")
|
|
59
|
+
if len(filters) > 0:
|
|
60
|
+
self._filters = {}
|
|
61
|
+
for _filter in filters:
|
|
62
|
+
filter_type = self._tg.parameter.get(
|
|
63
|
+
f"{_filter}/filter_type/String"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if filter_type == "biquad":
|
|
67
|
+
tg_filter = RtintHdwBiquad(self._tg, _filter)
|
|
68
|
+
elif filter_type == "remove_dc":
|
|
69
|
+
tg_filter = RtintRemoveDcFilter(self._tg, _filter)
|
|
70
|
+
elif filter_type == "general_fir":
|
|
71
|
+
tg_filter = RtintGeneralFirFilter(self._tg, _filter)
|
|
72
|
+
elif filter_type == "general_iir":
|
|
73
|
+
tg_filter = RtintGeneralIirFilter(self._tg, _filter)
|
|
74
|
+
elif filter_type == "pid":
|
|
75
|
+
tg_filter = RtintPidFilter(self._tg, _filter)
|
|
76
|
+
elif filter_type == "signal_characteristics":
|
|
77
|
+
tg_filter = RtintSignalCharacteristicsFilter(
|
|
78
|
+
self._tg, _filter
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
tg_filter = RtintHdwFilter(self._tg, _filter)
|
|
82
|
+
|
|
83
|
+
if hasattr(self, tg_filter._name):
|
|
84
|
+
print(
|
|
85
|
+
f"{RED}WARNING: Filter '{tg_filter._name}' already exists{RESET}"
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
else:
|
|
89
|
+
setattr(self, tg_filter._name, tg_filter)
|
|
90
|
+
self._filters[tg_filter._name] = tg_filter
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RtintHdwFilter:
|
|
94
|
+
def __init__(self, tg, unique_name):
|
|
95
|
+
self._tg = tg
|
|
96
|
+
self._unique_name = unique_name
|
|
97
|
+
self._type = None
|
|
98
|
+
self._filter_params = []
|
|
99
|
+
|
|
100
|
+
def __info__(self):
|
|
101
|
+
return tabulate(
|
|
102
|
+
self._get_info_lines(), tablefmt="plain", stralign="right"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _get_info_lines(self):
|
|
106
|
+
lines = []
|
|
107
|
+
lines.append(["Name", self._name])
|
|
108
|
+
lines.append(["Unique Name", self._unique_name])
|
|
109
|
+
lines.append(["", ""])
|
|
110
|
+
lines.append(["Type", self._type])
|
|
111
|
+
return lines
|
|
112
|
+
|
|
113
|
+
def _tree(self):
|
|
114
|
+
print("Parameters:")
|
|
115
|
+
self._tg.parameter._tree.subtree(
|
|
116
|
+
self._tg._program.name + "/" + self._unique_name
|
|
117
|
+
).show()
|
|
118
|
+
print("Signals:")
|
|
119
|
+
self._tg.signal._tree.subtree(
|
|
120
|
+
self._tg._program.name + "/" + self._unique_name
|
|
121
|
+
).show()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def _name(self):
|
|
125
|
+
return self._tg.parameter.get(f"{self._unique_name}/bliss_filter/String")
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def input(self):
|
|
129
|
+
return self._tg.signal.get(f"{self._unique_name}/input")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def output(self):
|
|
133
|
+
return self._tg.signal.get(f"{self._unique_name}/output")
|
|
134
|
+
|
|
135
|
+
def plot(
|
|
136
|
+
self,
|
|
137
|
+
f=None,
|
|
138
|
+
xlim_min=None,
|
|
139
|
+
xlim_max=None,
|
|
140
|
+
ylim_min=None,
|
|
141
|
+
ylim_max=None,
|
|
142
|
+
title=None,
|
|
143
|
+
directory=None,
|
|
144
|
+
file_name=None,
|
|
145
|
+
):
|
|
146
|
+
self._tg.utils._bode_plot(
|
|
147
|
+
[self._get_transfer_function()],
|
|
148
|
+
f_exp=f,
|
|
149
|
+
xlim_min=xlim_min,
|
|
150
|
+
xlim_max=xlim_max,
|
|
151
|
+
ylim_min=ylim_min,
|
|
152
|
+
ylim_max=ylim_max,
|
|
153
|
+
title=title,
|
|
154
|
+
directory=directory,
|
|
155
|
+
file_name=file_name,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RtintHdwBiquad(RtintHdwFilter):
|
|
160
|
+
"""Generic biquad IIR filter, runtime-configurable.
|
|
161
|
+
|
|
162
|
+
Implements: y[k] = b0*u[k] + b1*u[k-1] + b2*u[k-2] - a1*y[k-1] - a2*y[k-2]
|
|
163
|
+
|
|
164
|
+
Use the convenience methods (low_pass, high_pass, etc.) to compute and upload
|
|
165
|
+
Tustin-discretized coefficients, or write b0..b2, a1, a2 directly.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, tg, unique_name):
|
|
169
|
+
super().__init__(tg, unique_name)
|
|
170
|
+
self._filter_params = ["b0", "b1", "b2", "a1", "a2"]
|
|
171
|
+
self._type = "Biquad"
|
|
172
|
+
|
|
173
|
+
def __info__(self):
|
|
174
|
+
lines = super()._get_info_lines()
|
|
175
|
+
lines.append(["", ""])
|
|
176
|
+
lines.append(["Parameters:", ""])
|
|
177
|
+
for param in self._filter_params:
|
|
178
|
+
lines.append([param, getattr(self, param)])
|
|
179
|
+
return tabulate(lines, tablefmt="plain", stralign="right")
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def b0(self):
|
|
183
|
+
return self._tg.parameter.get(f"{self._unique_name}/b0/Gain")
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def b1(self):
|
|
187
|
+
return self._tg.parameter.get(f"{self._unique_name}/b1/Gain")
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def b2(self):
|
|
191
|
+
return self._tg.parameter.get(f"{self._unique_name}/b2/Gain")
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def a1(self):
|
|
195
|
+
return self._tg.parameter.get(f"{self._unique_name}/a1/Gain")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def a2(self):
|
|
199
|
+
return self._tg.parameter.get(f"{self._unique_name}/a2/Gain")
|
|
200
|
+
|
|
201
|
+
def _upload(self, b, a):
|
|
202
|
+
"""Write [b0,b1,b2] and [1,a1,a2] to real-time target, then reset states."""
|
|
203
|
+
self._tg.parameter.set(f"{self._unique_name}/b0/Gain", b[0])
|
|
204
|
+
self._tg.parameter.set(f"{self._unique_name}/b1/Gain", b[1])
|
|
205
|
+
self._tg.parameter.set(f"{self._unique_name}/b2/Gain", b[2])
|
|
206
|
+
self._tg.parameter.set(f"{self._unique_name}/a1/Gain", a[1])
|
|
207
|
+
self._tg.parameter.set(f"{self._unique_name}/a2/Gain", a[2])
|
|
208
|
+
self.reset()
|
|
209
|
+
|
|
210
|
+
def reset(self):
|
|
211
|
+
"""Increment reset_trigger to flush internal biquad states."""
|
|
212
|
+
reset_val = int(
|
|
213
|
+
self._tg.parameter.get(f"{self._unique_name}/reset/Bias")
|
|
214
|
+
)
|
|
215
|
+
self._tg.parameter.set(f"{self._unique_name}/reset/Bias", reset_val + 1)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Convenience methods — Tustin (bilinear) discretization
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def low_pass(self, wn: float, xi: float = 0.707):
|
|
222
|
+
"""Second-order low-pass: wn^2 / (s^2 + 2*xi*wn*s + wn^2)."""
|
|
223
|
+
dt = self._tg._Ts
|
|
224
|
+
d = dt**2 * wn**2 + 4 * xi * dt * wn + 4
|
|
225
|
+
b = np.array([dt**2 * wn**2, 2 * dt**2 * wn**2, dt**2 * wn**2]) / d
|
|
226
|
+
a = (
|
|
227
|
+
np.array(
|
|
228
|
+
[d, 2 * dt**2 * wn**2 - 8, dt**2 * wn**2 - 4 * xi * dt * wn + 4]
|
|
229
|
+
)
|
|
230
|
+
/ d
|
|
231
|
+
)
|
|
232
|
+
self._upload(b, a)
|
|
233
|
+
|
|
234
|
+
def high_pass(self, wn: float, xi: float = 0.707):
|
|
235
|
+
"""Second-order high-pass: s^2 / (s^2 + 2*xi*wn*s + wn^2)."""
|
|
236
|
+
dt = self._tg._Ts
|
|
237
|
+
d = dt**2 * wn**2 + 4 * xi * dt * wn + 4
|
|
238
|
+
b = np.array([4, -8, 4]) / d
|
|
239
|
+
a = (
|
|
240
|
+
np.array(
|
|
241
|
+
[d, 2 * dt**2 * wn**2 - 8, dt**2 * wn**2 - 4 * xi * dt * wn + 4]
|
|
242
|
+
)
|
|
243
|
+
/ d
|
|
244
|
+
)
|
|
245
|
+
self._upload(b, a)
|
|
246
|
+
|
|
247
|
+
def first_order_low_pass(self, wn: float):
|
|
248
|
+
"""First-order low-pass: wn / (s + wn)."""
|
|
249
|
+
dt = self._tg._Ts
|
|
250
|
+
d = dt * wn + 2
|
|
251
|
+
b = np.array([dt * wn / d, dt * wn / d, 0.0])
|
|
252
|
+
a = np.array([1.0, (dt * wn - 2) / d, 0.0])
|
|
253
|
+
self._upload(b, a)
|
|
254
|
+
|
|
255
|
+
def first_order_high_pass(self, wn: float):
|
|
256
|
+
"""First-order high-pass: s / (s + wn)."""
|
|
257
|
+
dt = self._tg._Ts
|
|
258
|
+
d = dt * wn + 2
|
|
259
|
+
b = np.array([2 / d, -2 / d, 0.0])
|
|
260
|
+
a = np.array([1.0, (dt * wn - 2) / d, 0.0])
|
|
261
|
+
self._upload(b, a)
|
|
262
|
+
|
|
263
|
+
def notch(self, wn: float, xi: float, gc: float):
|
|
264
|
+
"""Notch: (s^2 + 2*xi*gc*wn*s + wn^2) / (s^2 + 2*xi*wn*s + wn^2).
|
|
265
|
+
|
|
266
|
+
gc < 1 for attenuation, gc > 1 for amplification at wn.
|
|
267
|
+
"""
|
|
268
|
+
dt = self._tg._Ts
|
|
269
|
+
d = dt**2 * wn**2 + 4 * xi * dt * wn + 4
|
|
270
|
+
b = (
|
|
271
|
+
np.array(
|
|
272
|
+
[
|
|
273
|
+
dt**2 * wn**2 + 4 * gc * xi * dt * wn + 4,
|
|
274
|
+
2 * dt**2 * wn**2 - 8,
|
|
275
|
+
dt**2 * wn**2 - 4 * gc * xi * dt * wn + 4,
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
/ d
|
|
279
|
+
)
|
|
280
|
+
a = (
|
|
281
|
+
np.array(
|
|
282
|
+
[d, 2 * dt**2 * wn**2 - 8, dt**2 * wn**2 - 4 * xi * dt * wn + 4]
|
|
283
|
+
)
|
|
284
|
+
/ d
|
|
285
|
+
)
|
|
286
|
+
self._upload(b, a)
|
|
287
|
+
|
|
288
|
+
def integrator(self):
|
|
289
|
+
"""Tustin integrator: 1/s."""
|
|
290
|
+
dt = self._tg._Ts
|
|
291
|
+
b = np.array([dt / 2, dt / 2, 0.0])
|
|
292
|
+
a = np.array([1.0, -1.0, 0.0])
|
|
293
|
+
self._upload(b, a)
|
|
294
|
+
|
|
295
|
+
def passthrough(self):
|
|
296
|
+
"""Pass-through: y[k] = u[k]."""
|
|
297
|
+
self._upload(np.array([1.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]))
|
|
298
|
+
|
|
299
|
+
def zero(self):
|
|
300
|
+
"""Zero output: y[k] = 0."""
|
|
301
|
+
self._upload(np.array([0.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]))
|
|
302
|
+
|
|
303
|
+
def lead(self, wn: float, a: float):
|
|
304
|
+
"""Lead filter: (s + wn) / (s + a*wn), a > 1."""
|
|
305
|
+
dt = self._tg._Ts
|
|
306
|
+
d = dt * a * wn + 2
|
|
307
|
+
b = np.array([(2 * a + dt * wn) / d, (dt * wn - 2 * a) / d, 0.0])
|
|
308
|
+
den = np.array([1.0, (dt * a * wn - 2) / d, 0.0])
|
|
309
|
+
self._upload(b, den)
|
|
310
|
+
|
|
311
|
+
def lag(self, wn: float, a: float):
|
|
312
|
+
"""Lag filter, a < 1."""
|
|
313
|
+
dt = self._tg._Ts
|
|
314
|
+
d = dt * wn + 2 * a**0.5
|
|
315
|
+
b = np.array(
|
|
316
|
+
[(2 * a**0.5 + dt * a * wn) / d, (dt * a * wn - 2 * a**0.5) / d, 0.0]
|
|
317
|
+
)
|
|
318
|
+
den = np.array([1.0, (dt * wn - 2 * a**0.5) / d, 0.0])
|
|
319
|
+
self._upload(b, den)
|
|
320
|
+
|
|
321
|
+
def _get_transfer_function(self):
|
|
322
|
+
dt = self._tg._Ts
|
|
323
|
+
b = [self.b0, self.b1, self.b2]
|
|
324
|
+
a = [1.0, self.a1, self.a2]
|
|
325
|
+
return dlti(b, a, dt=dt)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class RtintRemoveDcFilter(RtintHdwFilter):
|
|
329
|
+
"""Filter that can be triggered to remove the DC part of the signal."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, tg, unique_name):
|
|
332
|
+
super().__init__(tg, unique_name)
|
|
333
|
+
self._type = "Remove DC"
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def offset(self):
|
|
337
|
+
return self._tg.signal.get(f"{self._unique_name}/param_offset")
|
|
338
|
+
|
|
339
|
+
@offset.setter
|
|
340
|
+
def offset(self, value):
|
|
341
|
+
try:
|
|
342
|
+
self._tg.parameter.set(f"{self._unique_name}/offset", value)
|
|
343
|
+
except Exception:
|
|
344
|
+
print("offset is dynamically set within the real-time target")
|
|
345
|
+
|
|
346
|
+
def trigger(self):
|
|
347
|
+
trigger_value = int(
|
|
348
|
+
self._tg.parameter.get(f"{self._unique_name}/start_trigger/Bias")
|
|
349
|
+
)
|
|
350
|
+
self._tg.parameter.set(
|
|
351
|
+
f"{self._unique_name}/start_trigger/Bias", trigger_value + 1
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _get_transfer_function(self):
|
|
355
|
+
dt = self._tg._Ts
|
|
356
|
+
|
|
357
|
+
return dlti([1], [1], dt=dt)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class RtintGeneralFirFilter(RtintHdwFilter):
|
|
361
|
+
"""Finite Impulse Response (FIR) filter.
|
|
362
|
+
|
|
363
|
+
Implements:
|
|
364
|
+
y[k] = b0*u[k] + b1*u[k-1] + ... + bN*u[k-N]
|
|
365
|
+
|
|
366
|
+
Transfer function (z-domain):
|
|
367
|
+
|
|
368
|
+
H(z) = b0 + b1*z^-1 + ... + bN*z^-N
|
|
369
|
+
|
|
370
|
+
where num_coef = [b0, b1, ..., bN]. The denominator is implicitly
|
|
371
|
+
1 (all poles at z = 0), so every FIR filter is unconditionally stable.
|
|
372
|
+
|
|
373
|
+
Parameters
|
|
374
|
+
----------
|
|
375
|
+
num_coef : array of numerator coefficients [b0, b1, ..., bN]
|
|
376
|
+
Length is fixed by the Simulink model (max_taps).
|
|
377
|
+
Coefficients beyond the desired order should be set to 0.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
def __init__(self, tg, unique_name):
|
|
381
|
+
super().__init__(tg, unique_name)
|
|
382
|
+
self._filter_params = ["order", "num_coef"]
|
|
383
|
+
self._type = "General FIR"
|
|
384
|
+
self._max_taps: int = self._tg._program.tree.signals[
|
|
385
|
+
f"{self._tg._program.name}/{self._unique_name}/param_num_coef"
|
|
386
|
+
].shape[0]
|
|
387
|
+
|
|
388
|
+
def __info__(self):
|
|
389
|
+
lines = super()._get_info_lines()
|
|
390
|
+
lines.append(["", ""])
|
|
391
|
+
lines.append(["Parameters:", ""])
|
|
392
|
+
lines.append(["Order", self.order])
|
|
393
|
+
lines.append(["Max taps", self.max_taps])
|
|
394
|
+
return tabulate(lines, tablefmt="plain", stralign="right")
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def order(self):
|
|
398
|
+
return len(self.num_coef) - 1
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def max_taps(self) -> int:
|
|
402
|
+
return self._max_taps
|
|
403
|
+
|
|
404
|
+
def moving_average(self, avg_time: float):
|
|
405
|
+
dt = self._tg._Ts
|
|
406
|
+
n_taps = round(avg_time / dt)
|
|
407
|
+
max_avg_time = self._max_taps * dt
|
|
408
|
+
if n_taps > self._max_taps:
|
|
409
|
+
print(
|
|
410
|
+
f"{RED}WARNING: avg_time ({avg_time:.3f} s = {n_taps} taps) "
|
|
411
|
+
f"exceeds maximum ({max_avg_time:.3f} s = {self._max_taps} taps); "
|
|
412
|
+
f"clamping to maximum{RESET}"
|
|
413
|
+
)
|
|
414
|
+
n_taps = self._max_taps
|
|
415
|
+
coef = np.zeros(self._max_taps)
|
|
416
|
+
coef[:n_taps] = 1.0 / n_taps
|
|
417
|
+
self.num_coef = coef
|
|
418
|
+
self.reset()
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def num_coef(self):
|
|
422
|
+
return self._tg.signal.get(f"{self._unique_name}/param_num_coef")
|
|
423
|
+
|
|
424
|
+
@num_coef.setter
|
|
425
|
+
def num_coef(self, value):
|
|
426
|
+
try:
|
|
427
|
+
self._tg.parameter.set(f"{self._unique_name}/num_coef", value)
|
|
428
|
+
except Exception:
|
|
429
|
+
print("num_coef is dynamically set within the real-time target")
|
|
430
|
+
|
|
431
|
+
def reset(self):
|
|
432
|
+
reset_states = int(
|
|
433
|
+
self._tg.parameter.get(f"{self._unique_name}/reset/Bias")
|
|
434
|
+
)
|
|
435
|
+
self._tg.parameter.set(
|
|
436
|
+
f"{self._unique_name}/reset/Bias", reset_states + 1
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def passthrough(self):
|
|
440
|
+
"""Pass-through: num_coef = [1, 0, ..., 0] at current length."""
|
|
441
|
+
n = len(self.num_coef)
|
|
442
|
+
coef = np.zeros(n)
|
|
443
|
+
coef[0] = 1.0
|
|
444
|
+
self.num_coef = coef
|
|
445
|
+
self.reset()
|
|
446
|
+
|
|
447
|
+
def zero(self):
|
|
448
|
+
"""Zero output: num_coef = [0, ..., 0] at current length."""
|
|
449
|
+
n = len(self.num_coef)
|
|
450
|
+
self.num_coef = np.zeros(n)
|
|
451
|
+
self.reset()
|
|
452
|
+
|
|
453
|
+
def _get_transfer_function(self):
|
|
454
|
+
dt = self._tg._Ts
|
|
455
|
+
|
|
456
|
+
num = list(self.num_coef)
|
|
457
|
+
den = [1] + [0] * (len(num) - 1)
|
|
458
|
+
return dlti(num, den, dt=dt)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class RtintGeneralIirFilter(RtintHdwFilter):
|
|
462
|
+
"""Infinite Impulse Response (IIR) filter.
|
|
463
|
+
|
|
464
|
+
Implements:
|
|
465
|
+
y[k] = b0*u[k] + b1*u[k-1] + ... + bM*u[k-M]
|
|
466
|
+
- a1*y[k-1] - a2*y[k-2] - ... - aN*y[k-N]
|
|
467
|
+
|
|
468
|
+
Transfer function (z-domain):
|
|
469
|
+
|
|
470
|
+
H(z) = (b0 + b1*z^-1 + ... + bM*z^-M) / (1 + a1*z^-1 + ... + aN*z^-N)
|
|
471
|
+
|
|
472
|
+
where num_coef = [b0, b1, ..., bM] and den_coef = [1, a1, ..., aN].
|
|
473
|
+
den_coef[0] is always 1.0 (leading coefficient of the denominator).
|
|
474
|
+
|
|
475
|
+
Parameters
|
|
476
|
+
----------
|
|
477
|
+
num_coef : array of numerator coefficients [b0, b1, ..., bM]
|
|
478
|
+
den_coef : array of denominator coefficients [1, a1, ..., aN]
|
|
479
|
+
(leading a0 = 1 is included as the first element)
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def __init__(self, tg, unique_name):
|
|
483
|
+
super().__init__(tg, unique_name)
|
|
484
|
+
self._filter_params = ["order", "num_coef", "den_coef"]
|
|
485
|
+
self._type = "General IIR"
|
|
486
|
+
|
|
487
|
+
def __info__(self):
|
|
488
|
+
lines = super()._get_info_lines()
|
|
489
|
+
lines.append(["", ""])
|
|
490
|
+
lines.append(["Parameters:", ""])
|
|
491
|
+
lines.append(["Order", self.order])
|
|
492
|
+
lines.append(
|
|
493
|
+
["Numerator", "[" + ", ".join(f"{x:.2g}" for x in self.num_coef) + "]"]
|
|
494
|
+
)
|
|
495
|
+
lines.append(
|
|
496
|
+
["Denominator", "[" + ", ".join(f"{x:.2g}" for x in self.den_coef) + "]"]
|
|
497
|
+
)
|
|
498
|
+
return tabulate(lines, tablefmt="plain", stralign="right")
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def order(self):
|
|
502
|
+
return len(self.den_coef) - 1
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def num_coef(self):
|
|
506
|
+
return self._tg.signal.get(f"{self._unique_name}/param_num_coef")
|
|
507
|
+
|
|
508
|
+
@num_coef.setter
|
|
509
|
+
def num_coef(self, value):
|
|
510
|
+
try:
|
|
511
|
+
self._tg.parameter.set(f"{self._unique_name}/num_coef", value)
|
|
512
|
+
except Exception:
|
|
513
|
+
print("num_coef is dynamically set within the real-time target")
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def den_coef(self):
|
|
517
|
+
return self._tg.signal.get(f"{self._unique_name}/param_den_coef")
|
|
518
|
+
|
|
519
|
+
@den_coef.setter
|
|
520
|
+
def den_coef(self, value):
|
|
521
|
+
try:
|
|
522
|
+
self._tg.parameter.set(f"{self._unique_name}/den_coef", value)
|
|
523
|
+
except Exception:
|
|
524
|
+
print("den_coef is dynamically set within the real-time target")
|
|
525
|
+
|
|
526
|
+
def reset(self):
|
|
527
|
+
reset_states = int(
|
|
528
|
+
self._tg.parameter.get(f"{self._unique_name}/reset/Bias")
|
|
529
|
+
)
|
|
530
|
+
self._tg.parameter.set(
|
|
531
|
+
f"{self._unique_name}/reset/Bias", reset_states + 1
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def passthrough(self):
|
|
535
|
+
"""Pass-through: num_coef = [1, 0, ...], den_coef = [1, 0, ...]."""
|
|
536
|
+
n_num = len(self.num_coef)
|
|
537
|
+
n_den = len(self.den_coef)
|
|
538
|
+
num = np.zeros(n_num)
|
|
539
|
+
num[0] = 1.0
|
|
540
|
+
den = np.zeros(n_den)
|
|
541
|
+
den[0] = 1.0
|
|
542
|
+
self.num_coef = num
|
|
543
|
+
self.den_coef = den
|
|
544
|
+
self.reset()
|
|
545
|
+
|
|
546
|
+
def zero(self):
|
|
547
|
+
"""Zero output: num_coef = [0, ...], den_coef = [1, 0, ...]."""
|
|
548
|
+
n_num = len(self.num_coef)
|
|
549
|
+
n_den = len(self.den_coef)
|
|
550
|
+
den = np.zeros(n_den)
|
|
551
|
+
den[0] = 1.0
|
|
552
|
+
self.num_coef = np.zeros(n_num)
|
|
553
|
+
self.den_coef = den
|
|
554
|
+
self.reset()
|
|
555
|
+
|
|
556
|
+
def _get_transfer_function(self):
|
|
557
|
+
dt = self._tg._Ts
|
|
558
|
+
|
|
559
|
+
return dlti(self.num_coef, self.den_coef, dt=dt)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class RtintPidFilter(RtintHdwFilter):
|
|
563
|
+
"""PID controller with 1st-order derivative low-pass filter.
|
|
564
|
+
|
|
565
|
+
Continuous-time transfer function (when fi > 0, fd > 0):
|
|
566
|
+
|
|
567
|
+
K_pid(s) = k * (1 + s/(2*pi*fd)) / (1 + s/(2*pi*ft)) * (1 + 2*pi*fi/s)
|
|
568
|
+
|
|
569
|
+
Discrete-time transfer function (z-domain):
|
|
570
|
+
|
|
571
|
+
K_pid(z) = ( kp + ki*Ts / (1 - z^-1) + kd/Ts * (1 - z^-1) ) * LPF(z)
|
|
572
|
+
|
|
573
|
+
where kp, ki, kd are computed by Simulink from k/f_i/f_d/f_t,
|
|
574
|
+
and LPF(z) is the bilinear discretisation of 1 / (1 + s/(2*pi*ft)).
|
|
575
|
+
|
|
576
|
+
Parameters
|
|
577
|
+
----------
|
|
578
|
+
k : overall gain
|
|
579
|
+
f_i : integrator corner frequency [Hz] (set to 0 to disable integrator)
|
|
580
|
+
f_d : derivative corner frequency [Hz]:
|
|
581
|
+
- f_d > 0 : derivative enabled (standard PID behaviour)
|
|
582
|
+
- f_d = 0 : derivative disabled (PI or P controller)
|
|
583
|
+
- f_d < 0 : pure integrator mode (kp = 0, kd = 0)
|
|
584
|
+
f_t : low-pass corner frequency on derivative output [Hz]
|
|
585
|
+
|
|
586
|
+
The gain computation follows these rules:
|
|
587
|
+
|
|
588
|
+
ki = k * 2*pi*f_i (if f_i > 0, else ki = 0)
|
|
589
|
+
|
|
590
|
+
f_d > 0 : kp = k * (1 + f_i/f_d), kd = k / (2*pi*f_d)
|
|
591
|
+
f_d = 0 : kp = k, kd = 0
|
|
592
|
+
f_d < 0 : kp = 0, kd = 0
|
|
593
|
+
|
|
594
|
+
Common configurations:
|
|
595
|
+
|
|
596
|
+
| Config | f_i | f_d | kp | ki | kd |
|
|
597
|
+
|---------|-----|-----|-------------------------|-------------|---------------|
|
|
598
|
+
| PID | >0 | >0 | k*(1 + f_i/f_d) | k*2*pi*f_i | k/(2*pi*f_d) |
|
|
599
|
+
| PI | >0 | =0 | k | k*2*pi*f_i | 0 |
|
|
600
|
+
| PD | =0 | >0 | k*(1 + 0/f_d) = k | 0 | k/(2*pi*f_d) |
|
|
601
|
+
| P | =0 | =0 | k | 0 | 0 |
|
|
602
|
+
| Pure I | >0 | <0 | 0 | k*2*pi*f_i | 0 |
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
def __init__(self, tg, unique_name):
|
|
606
|
+
super().__init__(tg, unique_name)
|
|
607
|
+
self._type = "PID"
|
|
608
|
+
self._filter_params = [
|
|
609
|
+
"k",
|
|
610
|
+
"f_i",
|
|
611
|
+
"f_d",
|
|
612
|
+
"f_t",
|
|
613
|
+
"enable_lpf",
|
|
614
|
+
"sat_upper",
|
|
615
|
+
"sat_lower",
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
def __info__(self):
|
|
619
|
+
lines = super()._get_info_lines()
|
|
620
|
+
lines.append(["", ""])
|
|
621
|
+
lines.append(["Parameters:", ""])
|
|
622
|
+
lines.append(["k", self.k])
|
|
623
|
+
lines.append(["f_i", self.f_i])
|
|
624
|
+
lines.append(["f_d", self.f_d])
|
|
625
|
+
lines.append(["f_t", self.f_t])
|
|
626
|
+
lines.append(["enable_lpf", self.enable_lpf])
|
|
627
|
+
lines.append(["sat_upper", self.sat_upper])
|
|
628
|
+
lines.append(["sat_lower", self.sat_lower])
|
|
629
|
+
lines.append(["", ""])
|
|
630
|
+
lines.append(["Status:", ""])
|
|
631
|
+
sat_str = f"{RED}True{RESET}" if self.saturated else f"{GREEN}True{RESET}"
|
|
632
|
+
lines.append(["saturated", sat_str])
|
|
633
|
+
return tabulate(lines, tablefmt="plain", stralign="right")
|
|
634
|
+
|
|
635
|
+
# --- Ground-truth parameters (direct R/W hardware access) ---
|
|
636
|
+
|
|
637
|
+
@property
|
|
638
|
+
def k(self):
|
|
639
|
+
return self._tg.signal.get(f"{self._unique_name}/param_k")
|
|
640
|
+
|
|
641
|
+
@k.setter
|
|
642
|
+
def k(self, value):
|
|
643
|
+
try:
|
|
644
|
+
self._tg.parameter.set(f"{self._unique_name}/k", value)
|
|
645
|
+
except Exception:
|
|
646
|
+
print("k is dynamically set within the real-time target")
|
|
647
|
+
|
|
648
|
+
@property
|
|
649
|
+
def f_i(self):
|
|
650
|
+
return self._tg.signal.get(f"{self._unique_name}/param_fi")
|
|
651
|
+
|
|
652
|
+
@f_i.setter
|
|
653
|
+
def f_i(self, value):
|
|
654
|
+
try:
|
|
655
|
+
self._tg.parameter.set(f"{self._unique_name}/fi", value)
|
|
656
|
+
except Exception:
|
|
657
|
+
print("f_i is dynamically set within the real-time target")
|
|
658
|
+
|
|
659
|
+
@property
|
|
660
|
+
def f_d(self):
|
|
661
|
+
return self._tg.signal.get(f"{self._unique_name}/param_fd")
|
|
662
|
+
|
|
663
|
+
@f_d.setter
|
|
664
|
+
def f_d(self, value):
|
|
665
|
+
try:
|
|
666
|
+
self._tg.parameter.set(f"{self._unique_name}/fd", value)
|
|
667
|
+
except Exception:
|
|
668
|
+
print("f_d is dynamically set within the real-time target")
|
|
669
|
+
|
|
670
|
+
@property
|
|
671
|
+
def f_t(self):
|
|
672
|
+
return self._tg.signal.get(f"{self._unique_name}/param_ft")
|
|
673
|
+
|
|
674
|
+
@f_t.setter
|
|
675
|
+
def f_t(self, value):
|
|
676
|
+
try:
|
|
677
|
+
self._tg.parameter.set(f"{self._unique_name}/ft", value)
|
|
678
|
+
except Exception:
|
|
679
|
+
print("f_t is dynamically set within the real-time target")
|
|
680
|
+
|
|
681
|
+
@property
|
|
682
|
+
def enable_lpf(self):
|
|
683
|
+
return int(
|
|
684
|
+
self._tg.parameter.get(f"{self._unique_name}/enable_lpf/Value")
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
@enable_lpf.setter
|
|
688
|
+
def enable_lpf(self, value):
|
|
689
|
+
try:
|
|
690
|
+
self._tg.parameter.set(
|
|
691
|
+
f"{self._unique_name}/enable_lpf/Value", int(value)
|
|
692
|
+
)
|
|
693
|
+
except Exception:
|
|
694
|
+
print("enable_lpf is dynamically set within the real-time target")
|
|
695
|
+
|
|
696
|
+
# --- Private read-only signals (computed by Simulink) ---
|
|
697
|
+
|
|
698
|
+
@property
|
|
699
|
+
def _kp(self):
|
|
700
|
+
return self._tg.signal.get(f"{self._unique_name}/param_kp")
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def _ki(self):
|
|
704
|
+
return self._tg.signal.get(f"{self._unique_name}/param_ki")
|
|
705
|
+
|
|
706
|
+
@property
|
|
707
|
+
def _kd(self):
|
|
708
|
+
return self._tg.signal.get(f"{self._unique_name}/param_kd")
|
|
709
|
+
|
|
710
|
+
# --- Saturation limits ---
|
|
711
|
+
|
|
712
|
+
@property
|
|
713
|
+
def sat_upper(self):
|
|
714
|
+
return self._tg.signal.get(f"{self._unique_name}/param_sat_upper")
|
|
715
|
+
|
|
716
|
+
@sat_upper.setter
|
|
717
|
+
def sat_upper(self, value):
|
|
718
|
+
try:
|
|
719
|
+
self._tg.parameter.set(f"{self._unique_name}/sat_upper", value)
|
|
720
|
+
except Exception:
|
|
721
|
+
print("sat_upper is dynamically set within the real-time target")
|
|
722
|
+
|
|
723
|
+
@property
|
|
724
|
+
def sat_lower(self):
|
|
725
|
+
return self._tg.signal.get(f"{self._unique_name}/param_sat_lower")
|
|
726
|
+
|
|
727
|
+
@sat_lower.setter
|
|
728
|
+
def sat_lower(self, value):
|
|
729
|
+
try:
|
|
730
|
+
self._tg.parameter.set(f"{self._unique_name}/sat_lower", value)
|
|
731
|
+
except Exception:
|
|
732
|
+
print("sat_lower is dynamically set within the real-time target")
|
|
733
|
+
|
|
734
|
+
# --- Read-only status ---
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def saturated(self):
|
|
738
|
+
return bool(self._tg.signal.get(f"{self._unique_name}/saturated"))
|
|
739
|
+
|
|
740
|
+
# --- Reset ---
|
|
741
|
+
|
|
742
|
+
def reset(self):
|
|
743
|
+
reset_states = int(
|
|
744
|
+
self._tg.parameter.get(f"{self._unique_name}/reset/Bias")
|
|
745
|
+
)
|
|
746
|
+
self._tg.parameter.set(
|
|
747
|
+
f"{self._unique_name}/reset/Bias", reset_states + 1
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def passthrough(self):
|
|
751
|
+
"""Pass-through: k=1, f_i=0, f_d=0, LPF disabled.
|
|
752
|
+
|
|
753
|
+
With the LPF disabled, the TF is exactly 1 (no approximation).
|
|
754
|
+
"""
|
|
755
|
+
self.k = 1.0
|
|
756
|
+
self.f_i = 0.0
|
|
757
|
+
self.f_d = 0.0
|
|
758
|
+
self.enable_lpf = 0
|
|
759
|
+
self.reset()
|
|
760
|
+
|
|
761
|
+
def zero(self):
|
|
762
|
+
"""Zero output: k=0 → kp=ki=kd=0 → TF = 0."""
|
|
763
|
+
self.k = 0.0
|
|
764
|
+
self.reset()
|
|
765
|
+
|
|
766
|
+
# --- Transfer function ---
|
|
767
|
+
|
|
768
|
+
def _get_transfer_function(self):
|
|
769
|
+
dt = self._tg._Ts
|
|
770
|
+
|
|
771
|
+
kp = self._kp
|
|
772
|
+
ki = self._ki
|
|
773
|
+
kd = self._kd
|
|
774
|
+
f_t = self.f_t
|
|
775
|
+
|
|
776
|
+
# P + I(Forward Euler) + D(Backward Euler), common denominator z(z-1):
|
|
777
|
+
# P → kp·z(z-1), I → ki·Ts·z², D → kd/Ts·(z-1)²
|
|
778
|
+
# num = (kp + ki·Ts + kd/Ts)·z² + (-kp - 2·kd/Ts)·z + kd/Ts
|
|
779
|
+
pid_num = np.array(
|
|
780
|
+
[
|
|
781
|
+
kp + ki * dt + kd / dt,
|
|
782
|
+
-kp - 2 * kd / dt,
|
|
783
|
+
kd / dt,
|
|
784
|
+
]
|
|
785
|
+
)
|
|
786
|
+
pid_den = np.array([1, -1, 0]) # z(z-1)
|
|
787
|
+
|
|
788
|
+
# LP filter (Tustin): (z + 1) / ((1+alpha)*z + (1-alpha))
|
|
789
|
+
# alpha = 1/(pi*f_t*Ts); skipped when LPF is disabled (enable_lpf = 0)
|
|
790
|
+
if self.enable_lpf:
|
|
791
|
+
alpha = 1 / (np.pi * f_t * dt)
|
|
792
|
+
lp_num = np.array([1, 1])
|
|
793
|
+
lp_den = np.array([1 + alpha, 1 - alpha])
|
|
794
|
+
num = np.polymul(pid_num, lp_num)
|
|
795
|
+
den = np.polymul(pid_den, lp_den)
|
|
796
|
+
else:
|
|
797
|
+
num = pid_num
|
|
798
|
+
den = pid_den
|
|
799
|
+
|
|
800
|
+
return dlti(num, den, dt=dt)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class RtintSignalCharacteristicsFilter(RtintHdwFilter):
|
|
804
|
+
"""Signal characteristics filter: computes running mean, variance and std.
|
|
805
|
+
|
|
806
|
+
Discrete-time transfer function (input -> mean, Forward Euler):
|
|
807
|
+
|
|
808
|
+
H(z) = (Ts/tau) / (1 - (1 - Ts/tau) * z^-1)
|
|
809
|
+
|
|
810
|
+
Signals
|
|
811
|
+
-------
|
|
812
|
+
signal_value : input signal (raw)
|
|
813
|
+
mean : running mean (= output)
|
|
814
|
+
variance : running variance
|
|
815
|
+
std : running standard deviation
|
|
816
|
+
|
|
817
|
+
Parameters
|
|
818
|
+
----------
|
|
819
|
+
time_constant : averaging time constant tau [s]
|
|
820
|
+
"""
|
|
821
|
+
|
|
822
|
+
def __init__(self, tg, unique_name):
|
|
823
|
+
super().__init__(tg, unique_name)
|
|
824
|
+
self._type = "Signal Characteristics"
|
|
825
|
+
self._filter_params = ["time_constant"]
|
|
826
|
+
|
|
827
|
+
def __info__(self):
|
|
828
|
+
lines = super()._get_info_lines()
|
|
829
|
+
lines.append(["", ""])
|
|
830
|
+
lines.append(["Parameters:", ""])
|
|
831
|
+
lines.append(["time_constant", self.time_constant])
|
|
832
|
+
lines.append(["", ""])
|
|
833
|
+
lines.append(["Signals:", ""])
|
|
834
|
+
lines.append(["mean", self.mean])
|
|
835
|
+
lines.append(["variance", self.variance])
|
|
836
|
+
lines.append(["std", self.std])
|
|
837
|
+
return tabulate(lines, tablefmt="plain", stralign="right")
|
|
838
|
+
|
|
839
|
+
@property
|
|
840
|
+
def time_constant(self):
|
|
841
|
+
return self._tg.signal.get(f"{self._unique_name}/param_time_constant")
|
|
842
|
+
|
|
843
|
+
@time_constant.setter
|
|
844
|
+
def time_constant(self, value):
|
|
845
|
+
try:
|
|
846
|
+
self._tg.parameter.set(f"{self._unique_name}/time_constant", value)
|
|
847
|
+
except Exception:
|
|
848
|
+
print("time_constant is dynamically set within the real-time target")
|
|
849
|
+
|
|
850
|
+
@property
|
|
851
|
+
def mean(self):
|
|
852
|
+
return self._tg.signal.get(f"{self._unique_name}/output")
|
|
853
|
+
|
|
854
|
+
@property
|
|
855
|
+
def signal_value(self):
|
|
856
|
+
return self._tg.signal.get(f"{self._unique_name}/input")
|
|
857
|
+
|
|
858
|
+
@property
|
|
859
|
+
def variance(self):
|
|
860
|
+
return self._tg.signal.get(f"{self._unique_name}/signal_variance")
|
|
861
|
+
|
|
862
|
+
@property
|
|
863
|
+
def std(self):
|
|
864
|
+
return self._tg.signal.get(f"{self._unique_name}/signal_std")
|
|
865
|
+
|
|
866
|
+
def _get_transfer_function(self):
|
|
867
|
+
dt = self._tg._Ts
|
|
868
|
+
tau = self.time_constant
|
|
869
|
+
alpha = dt / tau
|
|
870
|
+
num = [alpha, 0]
|
|
871
|
+
den = [1, -(1 - alpha)]
|
|
872
|
+
return dlti(num, den, dt=dt)
|