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/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)