pathsim 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pathsim/__init__.py +3 -0
  2. pathsim/blocks/__init__.py +14 -0
  3. pathsim/blocks/_block.py +209 -0
  4. pathsim/blocks/adder.py +30 -0
  5. pathsim/blocks/amplifier.py +34 -0
  6. pathsim/blocks/delay.py +70 -0
  7. pathsim/blocks/differentiator.py +70 -0
  8. pathsim/blocks/function.py +82 -0
  9. pathsim/blocks/integrator.py +66 -0
  10. pathsim/blocks/lti.py +155 -0
  11. pathsim/blocks/multiplier.py +30 -0
  12. pathsim/blocks/ode.py +86 -0
  13. pathsim/blocks/rf/__init__.py +4 -0
  14. pathsim/blocks/rf/filters.py +169 -0
  15. pathsim/blocks/rf/noise.py +218 -0
  16. pathsim/blocks/rf/sources.py +163 -0
  17. pathsim/blocks/rf/wienerhammerstein.py +338 -0
  18. pathsim/blocks/rng.py +57 -0
  19. pathsim/blocks/scope.py +224 -0
  20. pathsim/blocks/sources.py +71 -0
  21. pathsim/blocks/spectrum.py +316 -0
  22. pathsim/connection.py +112 -0
  23. pathsim/simulation.py +652 -0
  24. pathsim/solvers/__init__.py +25 -0
  25. pathsim/solvers/_solver.py +403 -0
  26. pathsim/solvers/bdf.py +240 -0
  27. pathsim/solvers/dirk2.py +101 -0
  28. pathsim/solvers/dirk3.py +86 -0
  29. pathsim/solvers/esdirk32.py +131 -0
  30. pathsim/solvers/esdirk4.py +99 -0
  31. pathsim/solvers/esdirk43.py +139 -0
  32. pathsim/solvers/esdirk54.py +141 -0
  33. pathsim/solvers/esdirk85.py +200 -0
  34. pathsim/solvers/euler.py +81 -0
  35. pathsim/solvers/rk4.py +61 -0
  36. pathsim/solvers/rkbs32.py +101 -0
  37. pathsim/solvers/rkck54.py +108 -0
  38. pathsim/solvers/rkdp54.py +111 -0
  39. pathsim/solvers/rkdp87.py +116 -0
  40. pathsim/solvers/rkf45.py +102 -0
  41. pathsim/solvers/rkf78.py +111 -0
  42. pathsim/solvers/rkv65.py +103 -0
  43. pathsim/solvers/ssprk22.py +62 -0
  44. pathsim/solvers/ssprk33.py +65 -0
  45. pathsim/solvers/ssprk34.py +74 -0
  46. pathsim/subsystem.py +267 -0
  47. pathsim/utils/__init__.py +0 -0
  48. pathsim/utils/adaptivebuffer.py +87 -0
  49. pathsim/utils/anderson.py +180 -0
  50. pathsim/utils/funcs.py +205 -0
  51. pathsim/utils/gilbert.py +110 -0
  52. pathsim/utils/progresstracker.py +90 -0
  53. pathsim/utils/realtimeplotter.py +230 -0
  54. pathsim/utils/statespacerealizations.py +116 -0
  55. pathsim/utils/waveforms.py +36 -0
  56. pathsim-0.2.0.dist-info/LICENSE.txt +21 -0
  57. pathsim-0.2.0.dist-info/METADATA +149 -0
  58. pathsim-0.2.0.dist-info/RECORD +109 -0
  59. pathsim-0.2.0.dist-info/WHEEL +5 -0
  60. pathsim-0.2.0.dist-info/top_level.txt +2 -0
  61. tests/__init__.py +0 -0
  62. tests/blocks/__init__.py +0 -0
  63. tests/blocks/test_adder.py +85 -0
  64. tests/blocks/test_amplifier.py +66 -0
  65. tests/blocks/test_block.py +138 -0
  66. tests/blocks/test_delay.py +122 -0
  67. tests/blocks/test_differentiator.py +102 -0
  68. tests/blocks/test_function.py +165 -0
  69. tests/blocks/test_integrator.py +92 -0
  70. tests/blocks/test_lti.py +162 -0
  71. tests/blocks/test_multiplier.py +87 -0
  72. tests/blocks/test_ode.py +125 -0
  73. tests/blocks/test_rng.py +109 -0
  74. tests/blocks/test_scope.py +196 -0
  75. tests/blocks/test_sources.py +119 -0
  76. tests/blocks/test_spectrum.py +119 -0
  77. tests/solvers/__init__.py +0 -0
  78. tests/solvers/test_bdf.py +364 -0
  79. tests/solvers/test_dirk2.py +138 -0
  80. tests/solvers/test_dirk3.py +137 -0
  81. tests/solvers/test_esdirk32.py +158 -0
  82. tests/solvers/test_esdirk4.py +138 -0
  83. tests/solvers/test_esdirk43.py +158 -0
  84. tests/solvers/test_esdirk54.py +160 -0
  85. tests/solvers/test_esdirk85.py +157 -0
  86. tests/solvers/test_euler.py +223 -0
  87. tests/solvers/test_rk4.py +138 -0
  88. tests/solvers/test_rkbs32.py +159 -0
  89. tests/solvers/test_rkck54.py +157 -0
  90. tests/solvers/test_rkdp54.py +159 -0
  91. tests/solvers/test_rkdp87.py +157 -0
  92. tests/solvers/test_rkf45.py +159 -0
  93. tests/solvers/test_rkf78.py +160 -0
  94. tests/solvers/test_rkv65.py +160 -0
  95. tests/solvers/test_solver.py +119 -0
  96. tests/solvers/test_ssprk22.py +136 -0
  97. tests/solvers/test_ssprk33.py +136 -0
  98. tests/solvers/test_ssprk34.py +136 -0
  99. tests/test_connection.py +176 -0
  100. tests/test_simulation.py +271 -0
  101. tests/test_subsystem.py +182 -0
  102. tests/utils/__init__.py +0 -0
  103. tests/utils/test_adaptivebuffer.py +111 -0
  104. tests/utils/test_anderson.py +142 -0
  105. tests/utils/test_funcs.py +143 -0
  106. tests/utils/test_gilbert.py +108 -0
  107. tests/utils/test_progresstracker.py +144 -0
  108. tests/utils/test_realtimeplotter.py +122 -0
  109. tests/utils/test_statespacerealizations.py +107 -0
pathsim/utils/funcs.py ADDED
@@ -0,0 +1,205 @@
1
+ ########################################################################################
2
+ ##
3
+ ## UTILITY FUNCTIONS
4
+ ## (utils/funcs.py)
5
+ ##
6
+ ## Milan Rother 2023/24
7
+ ##
8
+ ########################################################################################
9
+
10
+ # IMPORTS ==============================================================================
11
+
12
+ from time import perf_counter
13
+
14
+ import numpy as np
15
+
16
+
17
+ # HELPERS ==============================================================================
18
+
19
+ def timer(func):
20
+ """
21
+ shows the execution time in milliseconds of the
22
+ function object passed for debugging purposes
23
+ """
24
+ def wrap_func(*args, **kwargs):
25
+ t1 = perf_counter()
26
+ result = func(*args, **kwargs)
27
+ t2 = perf_counter()
28
+ print(f"Function '{func.__name__!r}' executed in {(t2 - t1)*1e3:.2f}ms")
29
+ return result
30
+ return wrap_func
31
+
32
+
33
+ def dB(x):
34
+ """
35
+ Compute clipped decibel value (for signals) where
36
+ the minimum value is '-360dB'.
37
+ """
38
+ return 20.0*np.log10(np.clip(abs(x), 1e-18, None))
39
+
40
+
41
+ # HELPERS FOR SIMULATION ===============================================================
42
+
43
+ def dict_to_array(a):
44
+ return np.array([a[k] for k in sorted(a.keys())])
45
+
46
+
47
+ def array_to_dict(a):
48
+ if np.isscalar(a): return {0:a}
49
+ else: return dict(enumerate(a))
50
+
51
+
52
+ def rel_error(a, b):
53
+ """
54
+ Computes the relative error between two scalars.
55
+ It is robust to one of them being zero and falls
56
+ back to the absolute error in this case.
57
+
58
+ NOTE :
59
+ this is actually faster then inlining the
60
+ branching into the return statement
61
+ """
62
+ if a == 0.0: return abs(b)
63
+ else: return abs((a - b)/a)
64
+
65
+
66
+ def abs_error(a, b):
67
+ """
68
+ Computes the absolute error between two scalars.
69
+ """
70
+ return abs(a - b)
71
+
72
+
73
+ def max_error(a, b):
74
+ """
75
+ Computes the maximum absolute error / deviation between two
76
+ iterables such as lists with numerical values. Returns a scalar
77
+ value representing the maximum deviation.
78
+
79
+ NOTE:
80
+ this is actually faster then 'max' over a list comprehension
81
+ """
82
+ max_err = 0.0
83
+ for err in map(abs_error, a, b):
84
+ if err > max_err:
85
+ max_err = err
86
+ return max_err
87
+
88
+
89
+ def max_rel_error(a, b):
90
+ """
91
+ Computes the maximum relative error between two iterables
92
+ such as lists with numerical values. It is robust to one of
93
+ them being zero and falls back to the absolute error in this
94
+ case. It returns a scalar value representing the maximum
95
+ relative error.
96
+
97
+ NOTE:
98
+ this is actually faster then 'max' over a list comprehension
99
+ """
100
+ max_err = 0.0
101
+ for err in map(rel_error, a, b):
102
+ if err > max_err:
103
+ max_err = err
104
+ return max_err
105
+
106
+
107
+ def max_error_dicts(a, b):
108
+ """
109
+ Computes the maximum absolute error between two dictionaries
110
+ with numerical values. It returns a scalar value representing
111
+ the maximum absolute error.
112
+ """
113
+ return max_error(a.values(), b.values())
114
+
115
+
116
+ def max_rel_error_dicts(a, b):
117
+ """
118
+ Computes the maximum relative error between two dictionaries
119
+ with numerical values. It is robust to one of them being zero
120
+ and falls back to the absolute error in this case. It returns
121
+ a scalar value representing the maximum relative error.
122
+ """
123
+ return max_rel_error(a.values(), b.values())
124
+
125
+
126
+ # AUTOMATIC DIFFERENTIATION ============================================================
127
+
128
+ def numerical_jacobian(func, x, h=1e-8):
129
+ """
130
+ Numerically computes the jacobian of the function 'func' by
131
+ central differences with the stepsize 'h' which is set to
132
+ a default value of 'h=1e-8' which is the point where the
133
+ truncation error of the central differences balances with
134
+ the machine accuracy of 64bit floating point numbers.
135
+
136
+ INPUTS :
137
+ func : (function object) function to compute jacobian for
138
+ x : (float or array) value for function at which the jacobian is evaluated
139
+ h : (float) step size for central differences
140
+ """
141
+
142
+ #catch scalar case (gradient)
143
+ if np.isscalar(x):
144
+ return 0.5 * (func(x+h) - func(x-h)) / h
145
+
146
+ #perturbation matrix and jacobian
147
+ e = np.eye(len(x)) * h
148
+ return 0.5 * np.array([func(x_p) - func(x_m) for x_p, x_m in zip(x+e, x-e)]).T / h
149
+
150
+
151
+ def auto_jacobian(func):
152
+ """
153
+ Wraps a function object such that it computes the jacobian
154
+ of the function with respect to the first argument.
155
+
156
+ This is intended to compute the jacobian 'jac(x, u, t)' of
157
+ the right hand side function 'func(x, u, t)' of numerical
158
+ integrators with respect to 'x'.
159
+ """
160
+ def wrap_func(*args):
161
+ _x, *_args = args
162
+ return numerical_jacobian(lambda x: func(x, *_args), _x)
163
+ return wrap_func
164
+
165
+
166
+
167
+ # PATH ESTIMATION ======================================================================
168
+
169
+ def path_length_dfs(connections, starting_block, visited=set()):
170
+ """
171
+ Recursively compute the longest path (depth first search)
172
+ in a directed graph from a starting node / block.
173
+ """
174
+
175
+ #node already visited -> break cycles
176
+ if starting_block in visited:
177
+ return 0
178
+
179
+ #block without instant time component -> break cycles
180
+ if not len(starting_block):
181
+ return 0
182
+
183
+ #add starting node to set of visited nodes
184
+ visited.add(starting_block)
185
+
186
+ #length of paths from the starting nodes
187
+ max_length = 0
188
+
189
+ #iterate connections and explore the path from the target node
190
+ for conn in connections:
191
+
192
+ #find connections from starting block
193
+ src, _ = conn.source
194
+ if src == starting_block:
195
+
196
+ #iterate connection target blocks
197
+ for trg, _ in conn.targets:
198
+
199
+ #recursively compute the new longest path
200
+ length = path_length_dfs(connections, trg, visited.copy())
201
+ if length > max_length: max_length = length
202
+
203
+ #add the contribution of the starting node to longest path
204
+ return max_length + len(starting_block)
205
+
@@ -0,0 +1,110 @@
1
+ ########################################################################################
2
+ ##
3
+ ## METHODS FOR STATESPACE REALIZATIONS
4
+ ## (utils/gilbert.py)
5
+ ##
6
+ ## Milan Rother 2024
7
+ ##
8
+ ########################################################################################
9
+
10
+ # IMPORTS ==============================================================================
11
+
12
+ import numpy as np
13
+
14
+
15
+ # STATESPACE REALIZATION ===============================================================
16
+
17
+ def gilbert_realization(Poles=[], Residues=[], Const=0.0, tolerance=1e-9):
18
+
19
+ """
20
+ Build real valued statespace model from transfer function
21
+ in pole residue form by Gilberts method and an additional
22
+ similarity transformation to get fully real valued matrices.
23
+
24
+ pole residue form:
25
+ H(s) = Const + sum( Residues / (s - Poles) )
26
+
27
+ statespace form:
28
+ H(s) = C * (s*I - A)^-1 * B + D
29
+
30
+ NOTE :
31
+ The resulting system is identical to the so-called
32
+ 'Modal Form' and is a minimal realization.
33
+
34
+ INPUTS :
35
+ Poles : (array) real and complex poles
36
+ Residues : (array) array of real and complex residue matrices
37
+ Const : (array) matrix for constant term
38
+ tolerance : (float) relative tolerance for checking real poles
39
+ """
40
+
41
+ #make arrays
42
+ Poles = np.atleast_1d(Poles)
43
+ Residues = np.atleast_1d(Residues)
44
+
45
+ #check validity of args
46
+ if not len(Poles) or not len(Residues):
47
+ raise ValueError("No 'Poles' and 'Residues' defined!")
48
+
49
+ if len(Poles) != len(Residues):
50
+ raise ValueError("Same number of 'Poles' and 'Residues' have to be given!")
51
+
52
+ #check shape of residues for MIMO, etc
53
+ if Residues.ndim == 1:
54
+ N, m, n = Residues.size, 1, 1
55
+ Residues = np.reshape(Residues, (N, m, n))
56
+ elif Residues.ndim == 2:
57
+ N, m, n = *Residues.shape, 1
58
+ Residues = np.reshape(Residues, (N, m, n))
59
+ elif Residues.ndim == 3:
60
+ N, m, n = Residues.shape
61
+ else:
62
+ raise ValueError(f"shape mismatch of 'Residues': Residues.shape={Residues.shape}")
63
+
64
+ #initialize companion matrix
65
+ a = np.zeros((N, N))
66
+ b = np.zeros(N)
67
+
68
+ #residues
69
+ C = np.ones((m, n*N))
70
+
71
+ #go through poles and handle conjugate pairs
72
+ _Poles, _Residues = [], []
73
+ for p, R in zip(Poles, Residues):
74
+
75
+ #real pole
76
+ if np.isreal(p) or abs(np.imag(p) / np.real(p)) < tolerance:
77
+ _Poles.append(p.real)
78
+ _Residues.append(R.real)
79
+
80
+ #complex conjugate pair
81
+ elif np.imag(p) > 0.0:
82
+ _Poles.extend([p, np.conj(p)])
83
+ _Residues.extend([R, np.conj(R)])
84
+
85
+ #build real companion matrix from the poles
86
+ p_old = 0.0
87
+ for k, (p, R) in enumerate(zip(_Poles, _Residues)):
88
+
89
+ #check if complex conjugate
90
+ is_cc = (p.imag != 0.0 and p == np.conj(p_old))
91
+ p_old = p
92
+
93
+ a[k,k] = np.real(p)
94
+ b[k] = 1.0
95
+ if is_cc:
96
+ a[k, k-1] = - np.imag(p)
97
+ a[k-1, k] = np.imag(p)
98
+ b[k] = 0.0
99
+ b[k-1] = 2.0
100
+
101
+ #iterate columns of residue
102
+ for i in range(n):
103
+ C[:,k+N*i] = np.imag(R[:,i]) if is_cc else np.real(R[:,i])
104
+
105
+ #build block diagonal
106
+ A = np.kron(np.eye(n, dtype=float), a)
107
+ B = np.kron(np.eye(n, dtype=float), b).T
108
+ D = Const
109
+
110
+ return A, B, C, D
@@ -0,0 +1,90 @@
1
+ ########################################################################################
2
+ ##
3
+ ## PROGRESS TRACKER CLASS DEFINITION
4
+ ## (utils/progresstracker.py)
5
+ ##
6
+ ## Milan Rother 2023/24
7
+ ##
8
+ ########################################################################################
9
+
10
+ # IMPORTS ==============================================================================
11
+
12
+ from time import perf_counter
13
+ import logging
14
+
15
+
16
+ # HELPER CLASS =========================================================================
17
+
18
+ class ProgressTracker:
19
+ """
20
+ Class that manages progress tracking by providing a generator
21
+ interface that runs until an external condition is satisfied.
22
+ """
23
+
24
+ def __init__(self, logger=None, log_interval=10):
25
+
26
+ #set logger
27
+ self.logger = logger or logging.getLogger(__name__)
28
+
29
+ #generation condition
30
+ self.condition = True
31
+
32
+ #step counter
33
+ self.steps = 0
34
+ self.successful_steps = 0
35
+
36
+ #for progress display in percent
37
+ self.display_percentages = list(range(0, 101, log_interval))
38
+
39
+
40
+ def __iter__(self):
41
+
42
+ #starting progress tracker
43
+ if self.logger:
44
+ self.logger.info("STARTING progress tracker")
45
+
46
+ #computer time for performance estimate
47
+ starting_time = perf_counter()
48
+
49
+ #generate as long as 'self.condition' is 'True'
50
+ while self.condition:
51
+ yield
52
+
53
+ #compute tracker runtime
54
+ runtime = perf_counter() - starting_time
55
+
56
+ #log the runtime
57
+ if self.logger:
58
+ self.logger.info(f"FINISHED steps(total)={self.successful_steps}({self.steps}) runtime={runtime*1e3:.2f}ms")
59
+
60
+
61
+ def check(self, progress, success=False, msg=""):
62
+ """
63
+ Update the progress of the generator.
64
+
65
+ This method needs to be called within the iteration loop
66
+ to update the looping condition and the internal tracking.
67
+
68
+ INPUTS :
69
+ progress : (float) progress number between 0 and 1
70
+ success : (bool) was the update step successful?
71
+ msg : (string) additional logging message
72
+ """
73
+
74
+ #compute progress in percent (round to integer)
75
+ percentage = int(100 * progress)
76
+
77
+ #count successful steps
78
+ self.successful_steps += int(success)
79
+
80
+ #count total steps
81
+ self.steps += 1
82
+
83
+ #generation condition is progress less then 1
84
+ self.condition = progress < 1.0
85
+
86
+ #check if percentage can be displayed
87
+ if percentage >= self.display_percentages[0]:
88
+ self.display_percentages.pop(0)
89
+ if self.logger:
90
+ self.logger.info(f"progress={percentage:.0f}%"+msg)
@@ -0,0 +1,230 @@
1
+ #########################################################################################
2
+ ##
3
+ ## REALTIME PLOTTER CLASS
4
+ ## (utils/realtimeplotter.py)
5
+ ##
6
+ ## Milan Rother 2024
7
+ ##
8
+ #########################################################################################
9
+
10
+ # IMPORTS ===============================================================================
11
+
12
+ import matplotlib.pyplot as plt
13
+ import matplotlib.style as mplstyle
14
+ mplstyle.use("fast")
15
+
16
+ import numpy as np
17
+
18
+ import time
19
+ from collections import deque
20
+
21
+
22
+ # PLOTTER CLASS =========================================================================
23
+
24
+
25
+ class RealtimePlotter:
26
+ """
27
+ Class that manages a realtime plotting window that
28
+ can stream in x-y-data and update accordingly
29
+
30
+ INPUTS:
31
+ max_samples : (int) maximum number of samples to plot
32
+ update_interval : (float) time in seconds between refreshs
33
+ labels : (list of strings) labels for plot traces
34
+ x_label : (str) label for x-axis
35
+ y_label : (str) label for y-axis
36
+ """
37
+
38
+ def __init__(self, max_samples=None, update_interval=1, labels=[], x_label="", y_label=""):
39
+
40
+ #plotter settings
41
+ self.max_samples = max_samples
42
+ self.update_interval = update_interval
43
+ self.labels = labels
44
+ self.x_label = x_label
45
+ self.y_label = y_label
46
+
47
+ #figure initialization
48
+ self.fig, self.ax = plt.subplots(nrows=1,
49
+ ncols=1,
50
+ figsize=(8,4),
51
+ tight_layout=True,
52
+ dpi=120)
53
+
54
+ #custom colors
55
+ self.ax.set_prop_cycle(color=["#e41a1c",
56
+ "#377eb8",
57
+ "#4daf4a",
58
+ "#984ea3",
59
+ "#ff7f00"])
60
+
61
+ #plot settings
62
+ self.ax.set_xlabel(self.x_label)
63
+ self.ax.set_ylabel(self.y_label)
64
+ self.ax.grid(True)
65
+
66
+ #data and lines (traces) for plotting
67
+ self.lines = []
68
+ self.data = []
69
+
70
+ #tracking update time
71
+ self.last_update = time.time()
72
+
73
+ #flag for running mode
74
+ self.is_running = True
75
+
76
+ # Connect the close event to the on_close method
77
+ self.fig.canvas.mpl_connect("close_event", self.on_close)
78
+
79
+ # Initialize legend
80
+ self.legend = None
81
+ self.lined = {}
82
+
83
+ #show the plotting window
84
+ self.show()
85
+
86
+
87
+ def update_all(self, x, y):
88
+
89
+ #not running? -> quit early
90
+ if not self.is_running:
91
+ return False
92
+
93
+ #no data yet? -> initialize lines
94
+ if not self.data:
95
+
96
+ #data initialization
97
+ for i, val in enumerate(y):
98
+ self.data.append({"x": [], "y": []})
99
+
100
+ #label selection and line (trace) initialization
101
+ label = self.labels[i] if i < len(self.labels) else f"port {i}"
102
+ line, = self.ax.plot([], [], lw=1.5, label=label)
103
+ self.lines.append(line)
104
+
105
+ # Create legend
106
+ self.legend = self.ax.legend(fancybox=False, ncols=int(np.ceil(len(y)/4)), loc="lower left")
107
+ self._setup_legend_picking()
108
+
109
+ #check if new update of plot is required
110
+ current_time = time.time()
111
+ if current_time - self.last_update > self.update_interval:
112
+
113
+ #replace the data
114
+ for i, val in enumerate(y):
115
+ self.data[i]["x"] = x
116
+ self.data[i]["y"] = val
117
+
118
+ self._update_plot()
119
+ self.last_update = current_time
120
+
121
+ return True
122
+
123
+
124
+ def update(self, x, y):
125
+
126
+ #not running? -> quit early
127
+ if not self.is_running:
128
+ return False
129
+
130
+ #no data yet? -> initialize lines
131
+ if not self.data:
132
+
133
+ #vectorial data -> multiple traces
134
+ if np.isscalar(y):
135
+
136
+ #size of data
137
+ n = 1
138
+
139
+ #check if rolling window plot
140
+ if self.max_samples is None:
141
+ self.data.append({"x": [], "y": []})
142
+ else:
143
+ self.data.append({"x": deque(maxlen=self.max_samples),
144
+ "y": deque(maxlen=self.max_samples)})
145
+
146
+ #label selection and line (trace) initialization
147
+ label = self.labels[0] if self.labels else "port 0"
148
+ line, = self.ax.plot([], [], lw=1.5, label=label)
149
+ self.lines.append(line)
150
+
151
+ else:
152
+
153
+ #size of data
154
+ n = len(y)
155
+
156
+ for i in range(n):
157
+
158
+ #check if rolling window plot
159
+ if self.max_samples is None:
160
+ self.data.append({"x": [], "y": []})
161
+ else:
162
+ self.data.append({"x": deque(maxlen=self.max_samples),
163
+ "y": deque(maxlen=self.max_samples)})
164
+
165
+ #label selection and line (trace) initialization
166
+ label = self.labels[i] if i < len(self.labels) else f"port {i}"
167
+ line, = self.ax.plot([], [], lw=1.5, label=label)
168
+ self.lines.append(line)
169
+
170
+ # Create legend
171
+ self.legend = self.ax.legend(fancybox=False, ncols=int(np.ceil(n/4)), loc="lower left")
172
+ self._setup_legend_picking()
173
+
174
+ #add the data
175
+ if np.isscalar(y):
176
+ self.data[0]["x"].append(x)
177
+ self.data[0]["y"].append(y)
178
+ else:
179
+ for i, val in enumerate(y):
180
+ self.data[i]["x"].append(x)
181
+ self.data[i]["y"].append(val)
182
+
183
+ #check if new update of plot is required
184
+ current_time = time.time()
185
+ if current_time - self.last_update > self.update_interval:
186
+ self._update_plot()
187
+ self.last_update = current_time
188
+
189
+ return True
190
+
191
+
192
+ def _update_plot(self):
193
+
194
+ #set the data to the lines (traces) of the plot
195
+ for i, line in enumerate(self.lines):
196
+ line.set_data(self.data[i]["x"], self.data[i]["y"])
197
+
198
+ #rescale the window
199
+ self.ax.relim()
200
+ self.ax.autoscale_view()
201
+
202
+ #redraw the figure
203
+ self.fig.canvas.draw()
204
+ self.fig.canvas.flush_events()
205
+
206
+
207
+ def show(self):
208
+ plt.show(block=False)
209
+
210
+
211
+ def on_close(self, event):
212
+ self.is_running = False
213
+
214
+
215
+ def _setup_legend_picking(self):
216
+
217
+ #setup the picking for the legend lines
218
+ for legline, origline in zip(self.legend.get_lines(), self.lines):
219
+ legline.set_picker(5) # 5 points tolerance
220
+ self.lined[legline] = origline
221
+
222
+ def on_pick(event):
223
+ legline = event.artist
224
+ origline = self.lined[legline]
225
+ visible = not origline.get_visible()
226
+ origline.set_visible(visible)
227
+ legline.set_alpha(1.0 if visible else 0.2)
228
+ self.fig.canvas.draw()
229
+
230
+ self.fig.canvas.mpl_connect("pick_event", on_pick)