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.
@@ -0,0 +1,577 @@
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 Generators
8
+ """
9
+
10
+ import enum
11
+ import math
12
+ import numpy as np
13
+ from datetime import datetime
14
+ from scipy.signal import chirp
15
+ from tabulate import tabulate
16
+
17
+ RED = "\033[31m"
18
+ GREEN = "\033[32m"
19
+ RESET = "\033[0m"
20
+
21
+
22
+ def _force_fft_symmetry(X: np.ndarray) -> np.ndarray:
23
+ """Force conjugate symmetry so IFFT yields a real signal."""
24
+ Y = X.copy()
25
+ N = len(X)
26
+ half_floor = N // 2
27
+ half_ceil = math.ceil(N / 2)
28
+ Y[half_ceil + 1 :] = np.conj(X[1:half_floor][::-1])
29
+ return Y
30
+
31
+
32
+ def _schroeder_phases(Ns: int, mag: np.ndarray) -> np.ndarray:
33
+ """Schroeder phases for minimum crest factor (Schroeder 1970)."""
34
+ rel_mag = mag / mag.sum()
35
+ phase = np.zeros(Ns)
36
+ for n in range(1, Ns // 2 + 1):
37
+ ll = np.arange(n)
38
+ phase[n] = -2 * np.pi * np.sum((n - ll) * rel_mag[ll])
39
+ return phase
40
+
41
+
42
+ def _generate_multisine(
43
+ Fs: float,
44
+ Ns: int,
45
+ asd: np.ndarray = None,
46
+ phase_type: str = "schroeder",
47
+ ) -> np.ndarray:
48
+ """Generate a multi-sine excitation signal.
49
+
50
+ Args:
51
+ Fs: Sampling frequency [Hz].
52
+ Ns: Number of samples in the signal.
53
+ asd: Amplitude spectral density [unit/sqrt(Hz)], length Ns.
54
+ If None, a flat unitary ASD is used.
55
+ phase_type: 'schroeder' (deterministic, low crest factor) or 'normal' (random).
56
+
57
+ Returns:
58
+ Real-valued signal of length Ns, phase-shifted to start at the
59
+ minimum-gradient zero crossing.
60
+ """
61
+ # --- magnitude ---
62
+ if asd is None:
63
+ mag = (np.ones(Ns) * 2 * np.sqrt(Fs) / np.sqrt(Ns)) ** 2
64
+ mag[Ns // 2 :] = 0
65
+ else:
66
+ asd = np.asarray(asd, dtype=float)
67
+ if len(asd) != Ns:
68
+ raise ValueError(f"asd must have length Ns={Ns}, got {len(asd)}")
69
+ mag = (asd * 2 * np.sqrt(Fs) / np.sqrt(Ns)) ** 2
70
+
71
+ if np.any(mag[Ns // 2 :]):
72
+ print(
73
+ f"{RED}WARNING: Non-zero magnitude values present outside of frequency limits.{RESET}"
74
+ )
75
+ mag[Ns // 2 :] = 0
76
+
77
+ # --- phase ---
78
+ if phase_type == "schroeder":
79
+ if mag.sum() == 0:
80
+ raise ValueError("All magnitude bins are zero — frequency band is empty.")
81
+ phase = _schroeder_phases(Ns, mag)
82
+ elif phase_type == "normal":
83
+ phase = np.random.randn(Ns)
84
+ else:
85
+ raise ValueError("phase_type must be 'schroeder' or 'normal'")
86
+
87
+ # --- frequency → time domain ---
88
+ Y = np.sqrt(mag / 2) * np.exp(1j * phase)
89
+ y = np.fft.ifft(_force_fft_symmetry(Y)).real * (Ns / 2)
90
+
91
+ # --- align to minimum-gradient zero crossing ---
92
+ y_sign = y > 0
93
+ zero_inds = np.where(y_sign[1:] != y_sign[:-1])[0]
94
+ zero_grad = np.abs(y[zero_inds] - y[zero_inds + 1])
95
+ start = zero_inds[np.argmin(zero_grad)]
96
+ return np.concatenate([y[start:], y[:start]])
97
+
98
+
99
+ class RtintHdwGeneratorController:
100
+ def __init__(self, tg):
101
+ self._tg = tg
102
+ self._generators: dict[str, RtintHdwGenerator] | None = None
103
+ self._load()
104
+
105
+ def __info__(self, debug=False):
106
+ if self._generators is None:
107
+ return "\n No Generator in the model"
108
+
109
+ if debug:
110
+ lines = [
111
+ [
112
+ "Name",
113
+ "State",
114
+ "Duration [s]",
115
+ "Repeat",
116
+ "Unique Name",
117
+ "Max Duration [s]",
118
+ "Period [s]",
119
+ ]
120
+ ]
121
+ else:
122
+ lines = [["Name", "State", "Duration [s]", "Repeat"]]
123
+ for gen in self._generators.values():
124
+ repeat_str = f"True ({gen.period:.3f} s)" if gen.repeat else "False"
125
+ row = [
126
+ gen._name,
127
+ gen.state.name,
128
+ f"{gen.duration:.3f}",
129
+ repeat_str,
130
+ ]
131
+ if debug:
132
+ row += [
133
+ gen._unique_name,
134
+ f"{gen.max_duration:.3f}",
135
+ f"{gen.period:.3f}",
136
+ ]
137
+ lines.append(row)
138
+ return "\n" + tabulate(lines, headers="firstrow", tablefmt="grid", stralign="left")
139
+
140
+ def _load(self):
141
+ generators = self._tg._get_all_objects_from_key("bliss_signalgen")
142
+ if len(generators) > 0:
143
+ self._generators = {}
144
+ for generator in generators:
145
+ tg_generator = RtintHdwGenerator(self._tg, generator)
146
+ if hasattr(self, tg_generator._name):
147
+ print(
148
+ f"{RED}WARNING: Generator '{tg_generator._name}' already exists{RESET}"
149
+ )
150
+ return
151
+ else:
152
+ setattr(self, tg_generator._name, tg_generator)
153
+ self._generators[tg_generator._name] = tg_generator
154
+
155
+
156
+ class GeneratorState(enum.IntEnum):
157
+ Idle = 0
158
+ Moving = 1
159
+ Stopped = 2
160
+ Fault = 3
161
+
162
+
163
+ class RtintHdwGenerator:
164
+ def __init__(self, tg, unique_name):
165
+ self._tg = tg
166
+ self._unique_name = unique_name
167
+ self._max_samples: int = self._tg._program.tree.params[
168
+ f"{self._tg._program.name}/{self._unique_name}/samples/Value"
169
+ ].shape[0]
170
+
171
+ def __info__(self):
172
+ Ts = self._tg._Ts
173
+ n_active = round(self.duration / Ts)
174
+ lines = [
175
+ ["Name", self._name],
176
+ ["Unique name", self._unique_name],
177
+ ["", ""],
178
+ ["State", self.state.name],
179
+ ["", ""],
180
+ ["Duration", f"{self.duration:.3f} s ({n_active} samples)"],
181
+ [
182
+ "Max duration",
183
+ f"{self.max_duration:.3f} s ({self._max_samples} samples)",
184
+ ],
185
+ [
186
+ "Repeat",
187
+ (
188
+ f"True ({self.period:.3f} s, {self._period_samples} samples)"
189
+ if self.repeat
190
+ else "False"
191
+ ),
192
+ ],
193
+ ]
194
+ return tabulate(lines, tablefmt="plain", stralign="right")
195
+
196
+ def _tree(self):
197
+ print("Parameters:")
198
+ self._tg.parameter._tree.subtree(
199
+ self._tg._program.name + "/" + self._unique_name
200
+ ).show()
201
+ print("Signals:")
202
+ self._tg.signal._tree.subtree(
203
+ self._tg._program.name + "/" + self._unique_name
204
+ ).show()
205
+
206
+ @property
207
+ def _name(self):
208
+ return self._tg.parameter.get(
209
+ f"{self._unique_name}/bliss_signalgen/String"
210
+ )
211
+
212
+ # --- read-only ---
213
+
214
+ @property
215
+ def max_duration(self) -> float:
216
+ """Maximum signal duration [s], fixed at model compile time."""
217
+ return self._max_samples * self._tg._Ts
218
+
219
+ @property
220
+ def state(self) -> GeneratorState:
221
+ return GeneratorState(
222
+ int(self._tg.signal.get(f"{self._unique_name}/state"))
223
+ )
224
+
225
+ @property
226
+ def output(self) -> float:
227
+ return self._tg.signal.get(f"{self._unique_name}/gen_signal")
228
+
229
+ # --- samples ---
230
+
231
+ @property
232
+ def samples(self) -> np.ndarray:
233
+ return self._tg.parameter.get(f"{self._unique_name}/samples/Value")
234
+
235
+ @samples.setter
236
+ def samples(self, value):
237
+ signal = np.asarray(value, dtype=float).flatten()
238
+ if len(signal) > self._max_samples:
239
+ raise ValueError(
240
+ f"Signal length ({len(signal)} samples = {len(signal) * self._tg._Ts:.3f} s) "
241
+ f"exceeds maximum ({self._max_samples} samples = {self.max_duration:.3f} s)"
242
+ )
243
+ # Add zeros at the end of the vector if necessary
244
+ if len(signal) < self._max_samples:
245
+ signal = np.pad(signal, (0, self._max_samples - len(signal)))
246
+ self._tg.parameter.set(f"{self._unique_name}/samples/Value", signal)
247
+
248
+ # --- parameters ---
249
+
250
+ @property
251
+ def duration(self) -> float:
252
+ """Total playback duration [s]. Output is zero after this time."""
253
+ return self._tg.parameter.get(f"{self._unique_name}/duration/Value")
254
+
255
+ @duration.setter
256
+ def duration(self, value: float):
257
+ self._tg.parameter.set(f"{self._unique_name}/duration/Value", value)
258
+
259
+ @property
260
+ def repeat(self) -> bool:
261
+ """If True, the signal pattern cycles over period [s]."""
262
+ return bool(self._tg.parameter.get(f"{self._unique_name}/repeat/Value"))
263
+
264
+ @repeat.setter
265
+ def repeat(self, value: bool):
266
+ self._tg.parameter.set(f"{self._unique_name}/repeat/Value", int(value))
267
+
268
+ @property
269
+ def _period_samples(self) -> int:
270
+ return int(self._tg.parameter.get(f"{self._unique_name}/period/Value"))
271
+
272
+ @property
273
+ def period(self) -> float:
274
+ """Signal pattern period [s]. Stored as samples on the real-time target."""
275
+ return self._period_samples * self._tg._Ts
276
+
277
+ @period.setter
278
+ def period(self, value: float):
279
+ """Set signal pattern period [s]. Converted to samples internally."""
280
+ self._tg.parameter.set(
281
+ f"{self._unique_name}/period/Value", round(value / self._tg._Ts)
282
+ )
283
+
284
+ # --- start / stop ---
285
+
286
+ def start(self):
287
+ start_trigger = int(
288
+ self._tg.parameter.get(f"{self._unique_name}/start_trigger/Bias")
289
+ )
290
+ self._tg.parameter.set(
291
+ f"{self._unique_name}/start_trigger/Bias", start_trigger + 1
292
+ )
293
+
294
+ def stop(self):
295
+ stop_trigger = int(
296
+ self._tg.parameter.get(f"{self._unique_name}/stop_trigger/Bias")
297
+ )
298
+ self._tg.parameter.set(
299
+ f"{self._unique_name}/stop_trigger/Bias", stop_trigger + 1
300
+ )
301
+
302
+ # --- internal ---
303
+
304
+ @staticmethod
305
+ def _prepend_zeros(signal: np.ndarray, start_time: float, Ts: float) -> np.ndarray:
306
+ if start_time > 0:
307
+ return np.concatenate([np.zeros(int(start_time / Ts)), signal])
308
+ return signal
309
+
310
+ def _upload(self, signal: np.ndarray, duration: float = None):
311
+ """Upload signal pattern. Sets samples, period (= pattern length), and duration."""
312
+ self.samples = signal
313
+ pattern_duration = len(signal) * self._tg._Ts
314
+ self.period = pattern_duration
315
+ self.duration = duration if duration is not None else pattern_duration
316
+
317
+ # --- convenience methods ---
318
+ # Periodic signals (sinus, triangle, noise): upload one natural period, repeat=True by default.
319
+ # Pass duration= to set total playback time in the same call.
320
+ # Example: gen.sinus(1e-6, 1.0, duration=100); gen.start()
321
+ # One-shot signals (step, ramp, sweep, impulse): repeat=False by default.
322
+
323
+ def sinus(
324
+ self,
325
+ amplitude: float,
326
+ frequency: float,
327
+ offset: float = 0,
328
+ duration: float = None,
329
+ repeat: bool = True,
330
+ ):
331
+ """Upload one cycle of a sinus at frequency [Hz]. Repeats by default."""
332
+ Ts = self._tg._Ts
333
+ t = np.arange(0, 1.0 / frequency, Ts)
334
+ self._upload(offset + amplitude * np.sin(2 * np.pi * frequency * t), duration)
335
+ self.repeat = repeat
336
+
337
+ def triangle(
338
+ self,
339
+ amplitude: float,
340
+ frequency: float,
341
+ offset: float = 0,
342
+ duration: float = None,
343
+ repeat: bool = True,
344
+ ):
345
+ """Upload one cycle of a triangle wave at frequency [Hz]. Repeats by default."""
346
+ Ts = self._tg._Ts
347
+ period = 1.0 / frequency
348
+ t = np.arange(0, period, Ts)
349
+ slope = 4 * amplitude * frequency
350
+ t_mod = np.mod(t, period)
351
+ signal = np.where(
352
+ t_mod <= period / 4,
353
+ slope * t_mod,
354
+ np.where(
355
+ t_mod <= 3 * period / 4,
356
+ amplitude - slope * (t_mod - period / 4),
357
+ -amplitude + slope * (t_mod - 3 * period / 4),
358
+ ),
359
+ )
360
+ self._upload(offset + signal, duration)
361
+ self.repeat = repeat
362
+
363
+ def uniform_white_noise(
364
+ self,
365
+ amplitude: float,
366
+ pattern_duration: float,
367
+ offset: float = 0,
368
+ duration: float = None,
369
+ repeat: bool = True,
370
+ ):
371
+ """Upload a uniform noise pattern of pattern_duration [s]. Repeats by default."""
372
+ Ts = self._tg._Ts
373
+ n = int(pattern_duration / Ts)
374
+ self._upload(
375
+ offset + 2 * amplitude * (np.random.uniform(size=n) - 0.5), duration
376
+ )
377
+ self.repeat = repeat
378
+
379
+ def normal_white_noise(
380
+ self,
381
+ amplitude: float,
382
+ pattern_duration: float,
383
+ offset: float = 0,
384
+ duration: float = None,
385
+ repeat: bool = True,
386
+ ):
387
+ """Upload a normal noise pattern of pattern_duration [s]. Repeats by default."""
388
+ Ts = self._tg._Ts
389
+ n = int(pattern_duration / Ts)
390
+ self._upload(offset + 2 * amplitude * np.random.randn(n), duration)
391
+ self.repeat = repeat
392
+
393
+ def multisine(
394
+ self,
395
+ amplitude: float,
396
+ pattern_duration: float,
397
+ f_min: float = None,
398
+ f_max: float = None,
399
+ offset: float = 0,
400
+ duration: float = None,
401
+ asd: np.ndarray = None,
402
+ phase_type: str = "schroeder",
403
+ repeat: bool = True,
404
+ ):
405
+ """Upload a multi-sine excitation signal.
406
+
407
+ Args:
408
+ amplitude: Peak amplitude of the generated signal [unit].
409
+ The signal is scaled so max(|signal|) == amplitude.
410
+ pattern_duration: Length of one signal period [s].
411
+ f_min: Lower frequency bound [Hz]. Bins below are zeroed.
412
+ f_max: Upper frequency bound [Hz]. Must not exceed Fs/2.
413
+ offset: DC offset added to the signal.
414
+ duration: Total playback duration [s]. Defaults to pattern_duration.
415
+ asd: Optional shaped ASD array (relative weights), length Ns=int(pattern_duration/Ts).
416
+ When provided, shapes the frequency content; amplitude still sets the peak.
417
+ phase_type: 'schroeder' or 'normal'.
418
+ repeat: If True, the pattern cycles. Default True.
419
+ """
420
+ Ts = self._tg._Ts
421
+ Fs = 1.0 / Ts
422
+ Ns = int(pattern_duration / Ts)
423
+
424
+ if f_max is not None and f_max > Fs / 2:
425
+ raise ValueError(
426
+ f"f_max ({f_max} Hz) exceeds Nyquist frequency ({Fs / 2} Hz)"
427
+ )
428
+
429
+ if asd is None:
430
+ asd = np.ones(Ns, dtype=float)
431
+ if f_min is not None:
432
+ asd[: round(Ns * f_min / Fs)] = 0
433
+ if f_max is not None:
434
+ asd[round(Ns * f_max / Fs) + 1 :] = 0
435
+
436
+ signal = _generate_multisine(Fs, Ns, asd=asd, phase_type=phase_type)
437
+ signal *= amplitude / np.max(np.abs(signal))
438
+ self._upload(offset + signal, duration)
439
+ self.repeat = repeat
440
+
441
+ def step(
442
+ self,
443
+ amplitude: float,
444
+ duration: float,
445
+ offset: float = 0,
446
+ start_time: float = 0,
447
+ repeat: bool = False,
448
+ ):
449
+ Ts = self._tg._Ts
450
+ t = np.arange(0, duration, Ts)
451
+ self._upload(
452
+ self._prepend_zeros(offset + np.full_like(t, amplitude), start_time, Ts),
453
+ start_time + duration,
454
+ )
455
+ self.repeat = repeat
456
+
457
+ def impulse(
458
+ self,
459
+ amplitude: float,
460
+ start_time: float = 0,
461
+ offset: float = 0,
462
+ repeat: bool = False,
463
+ ):
464
+ Ts = self._tg._Ts
465
+ start_idx = int(start_time / Ts)
466
+ signal = np.zeros(start_idx + 1)
467
+ signal[start_idx] = amplitude
468
+ self._upload(offset + signal, start_time + Ts)
469
+ self.repeat = repeat
470
+
471
+ def ramp(
472
+ self,
473
+ amplitude: float,
474
+ duration: float,
475
+ offset: float = 0,
476
+ start_time: float = 0,
477
+ repeat: bool = False,
478
+ ):
479
+ Ts = self._tg._Ts
480
+ t = np.arange(0, duration, Ts)
481
+ self._upload(
482
+ self._prepend_zeros(offset + amplitude * (t / duration), start_time, Ts),
483
+ start_time + duration,
484
+ )
485
+ self.repeat = repeat
486
+
487
+ def linear_sweep(
488
+ self,
489
+ amplitude: float,
490
+ duration: float,
491
+ start_freq: float,
492
+ end_freq: float,
493
+ offset: float = 0,
494
+ start_time: float = 0,
495
+ repeat: bool = False,
496
+ ):
497
+ Ts = self._tg._Ts
498
+ t = np.arange(0, duration, Ts)
499
+ signal = amplitude * chirp(
500
+ t, f0=start_freq, f1=end_freq, t1=duration, method="linear"
501
+ )
502
+ self._upload(
503
+ self._prepend_zeros(offset + signal, start_time, Ts),
504
+ start_time + duration,
505
+ )
506
+ self.repeat = repeat
507
+
508
+ def log_sweep(
509
+ self,
510
+ amplitude: float,
511
+ duration: float,
512
+ start_freq: float,
513
+ end_freq: float,
514
+ offset: float = 0,
515
+ start_time: float = 0,
516
+ repeat: bool = False,
517
+ ):
518
+ Ts = self._tg._Ts
519
+ t = np.arange(0, duration, Ts)
520
+ signal = amplitude * chirp(
521
+ t, f0=start_freq, f1=end_freq, t1=duration, method="logarithmic"
522
+ )
523
+ self._upload(
524
+ self._prepend_zeros(offset + signal, start_time, Ts),
525
+ start_time + duration,
526
+ )
527
+ self.repeat = repeat
528
+
529
+ # --- plot ---
530
+
531
+ def plot(self, directory=None, file_name=None):
532
+
533
+ import matplotlib.pyplot as plt
534
+
535
+ Ts = self._tg._Ts
536
+ n_active = round(self.duration / Ts)
537
+
538
+ if self.repeat:
539
+ n_period = self._period_samples
540
+ period_signal = self.samples[:n_period]
541
+ n_repeats = int(np.ceil(n_active / n_period))
542
+ signal_plot = np.tile(period_signal, n_repeats)[:n_active]
543
+ x = np.arange(len(signal_plot)) * Ts
544
+ fig, ax = plt.subplots(figsize=(10, 3))
545
+ ax.plot(x, signal_plot)
546
+ for k in range(1, n_repeats):
547
+ ax.axvline(
548
+ k * n_period * Ts,
549
+ color="r",
550
+ linestyle="--",
551
+ alpha=0.6,
552
+ label="Period boundary" if k == 1 else "",
553
+ )
554
+ if n_repeats > 1:
555
+ ax.legend()
556
+ ax.set_title(
557
+ f"Signal (repeat, period={self.period:.3f} s, duration={self.duration:.3f} s)"
558
+ )
559
+ else:
560
+ signal_plot = self.samples[:n_active]
561
+ x = np.arange(len(signal_plot)) * Ts
562
+ fig, ax = plt.subplots(figsize=(10, 3))
563
+ ax.plot(x, signal_plot)
564
+ ax.set_title(f"Signal (duration={self.duration:.3f} s)")
565
+
566
+ ax.set_xlabel("Time [s]")
567
+ ax.set_ylabel("Amplitude")
568
+ ax.grid(True)
569
+ plt.tight_layout()
570
+
571
+ if directory is not None and file_name is not None:
572
+ now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
573
+ fig.savefig(f"{directory}/{now}_{file_name}.pdf")
574
+ fig.savefig(f"{directory}/{now}_{file_name}.png")
575
+ plt.close(fig)
576
+ else:
577
+ plt.show()