motulator-multiphase 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """
2
+ Multi-three-phase machine drives.
3
+
4
+ This package provides models and controls of machines with n three-phase winding
5
+ sets (e.g., dual-three-phase machines), based on the decoupled multi-stator (DMS)
6
+ transformation [#Rub2021]_. The common mode of the winding sets is equivalent to a
7
+ three-phase machine whose sets are connected in parallel. Therefore, the three-phase
8
+ machine models, magnetic models, and vector controllers of motulator are reused for
9
+ the common mode, while the differential modes are modeled and controlled separately.
10
+ The package is organized like `motulator.drive`::
11
+
12
+ from motulator_multiphase import control, model, utils
13
+
14
+ References
15
+ ----------
16
+ .. [#Rub2021] Rubino, Dordevic, Armando, Bojoi, Levi, "A novel matrix transformation
17
+ for decoupled control of modular multiphase PMSM drives," IEEE Trans. Power
18
+ Electron., 2021, https://doi.org/10.1109/TPEL.2020.3043083
19
+
20
+ """
@@ -0,0 +1,23 @@
1
+ """Helper functions copied from the private modules of motulator."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ def wrap(theta: float) -> Any:
10
+ """Limit the angle into the range [-pi, pi)."""
11
+ return np.mod(theta + np.pi, 2 * np.pi) - np.pi
12
+
13
+
14
+ def get_value(u: Any | Callable[[Any], Any] | None, x: Any) -> Any:
15
+ """Return `u(x)` if `u` is callable, otherwise `u`."""
16
+ if u is None:
17
+ raise ValueError("Input value cannot be None")
18
+ return u(x) if callable(u) else u
19
+
20
+
21
+ def empty_array() -> np.ndarray:
22
+ """Return an empty array."""
23
+ return np.array([])
@@ -0,0 +1,452 @@
1
+ """
2
+ Control of multi-three-phase machine drives.
3
+
4
+ The common mode of the three-phase sets is controlled by the vector controllers of
5
+ three-phase machines, and the differential modes are current-controlled.
6
+
7
+ """
8
+
9
+ from cmath import exp
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass, field
12
+ from math import isclose, pi
13
+ from typing import Any, Protocol
14
+
15
+ import numpy as np
16
+ from motulator.common.control import (
17
+ PWM,
18
+ ComplexPIController,
19
+ ControlSystem,
20
+ DiscreteComplexController,
21
+ PIController,
22
+ TimeSeries,
23
+ )
24
+ from motulator.common.utils import abc2complex
25
+ from motulator.drive.control.sm import SpeedController
26
+
27
+ from motulator_multiphase._helpers import empty_array, get_value, wrap
28
+ from motulator_multiphase.model import MultiThreePhaseDrive
29
+ from motulator_multiphase.utils import default_set_angles, dms_matrix
30
+
31
+ __all__ = [
32
+ "DifferentialModeController",
33
+ "DifferentialModeSignals",
34
+ "ExternalReferences",
35
+ "Measurements",
36
+ "MultiThreePhaseControlSystem",
37
+ ]
38
+
39
+ type Sharing = Sequence[float] | np.ndarray
40
+
41
+
42
+ # %%
43
+ class Feedbacks(Protocol):
44
+ """Protocol defining the required fields for the feedback signals."""
45
+
46
+ w_M: float # Mechanical angular speed
47
+ w_c: float # Angular speed of the coordinate system
48
+ theta_c: float # Angular position of the coordinate system
49
+ u_s: complex # Stator voltage (controller coordinates) for the control laws
50
+ u_dc: float # DC-bus voltage
51
+ i_s: complex # Current of the parallel-connected machine
52
+
53
+
54
+ class References(Protocol):
55
+ """Protocol defining the required fields for the reference signals."""
56
+
57
+ T_s: float # Sampling period for the next control step
58
+ tau_M: float # Torque reference
59
+ u_s: complex # Stator voltage reference
60
+ u_s_lim: complex # Limited voltage reference (set by the control system)
61
+ w_M: float | None # Speed reference (set by the control system)
62
+ d_abc: Sequence[float] # Duty ratios (set by the control system)
63
+
64
+
65
+ class VectorController(Protocol):
66
+ """Protocol defining the interface of the vector controllers of motulator."""
67
+
68
+ sensorless: bool
69
+ k_comp: float # Angle-advance factor
70
+
71
+ def get_feedback(
72
+ self,
73
+ u_s_ab: complex,
74
+ i_s_ab: complex,
75
+ w_M_meas: float | None,
76
+ theta_M_meas: float | None,
77
+ ) -> Any:
78
+ """Get feedback signals from measurements."""
79
+ ...
80
+
81
+ def compute_output(self, tau_M_ref: float, fbk: Any) -> Any:
82
+ """Compute control output from feedback signals."""
83
+ ...
84
+
85
+ def update(self, ref: Any, fbk: Any) -> None:
86
+ """Update controller states."""
87
+ ...
88
+
89
+ def post_process(self, ts: TimeSeries) -> None:
90
+ """Post-process controller outputs."""
91
+ ...
92
+
93
+
94
+ @dataclass
95
+ class Measurements:
96
+ """Measured signals."""
97
+
98
+ i_c_ab: np.ndarray # Currents of the sets in their own coordinates
99
+ u_dc: float
100
+ w_M: float | None = None
101
+ theta_M: float | None = None
102
+
103
+
104
+ @dataclass
105
+ class ExternalReferences:
106
+ """External reference signals."""
107
+
108
+ w_M: float | Callable[[float], float] | None = None
109
+ tau_M: float | Callable[[float], float] | None = None
110
+ k_share: Sharing | Callable[[float], Sharing] | None = None
111
+
112
+
113
+ @dataclass
114
+ class DifferentialModeSignals:
115
+ """DM signals in the controller coordinates, each of shape (n_sets - 1,)."""
116
+
117
+ i: np.ndarray # Currents
118
+ u: np.ndarray # Realized voltages
119
+ i_ref: np.ndarray = field(default_factory=empty_array)
120
+ u_ref: np.ndarray = field(default_factory=empty_array)
121
+ u_lim: np.ndarray = field(default_factory=empty_array) # Limited u_ref
122
+
123
+
124
+ # %%
125
+ class DifferentialModeController:
126
+ """
127
+ Current controller of the differential modes.
128
+
129
+ Each DM is a resistive-inductive circuit with the leakage inductance `L_ls`. The DM
130
+ currents are controlled in the coordinates of the vector controller using the
131
+ flux-linkage-based complex-vector 2DOF controllers, in the same manner as in the
132
+ current controllers of motulator. The DM current references correspond to the
133
+ current-sharing coefficients of the sets, i.e., the reference of the set k is
134
+ `k_share[k]*i_cm`, where `i_cm` is the common-mode current.
135
+
136
+ Parameters
137
+ ----------
138
+ L_ls : float
139
+ Leakage inductance (H) of a set.
140
+ n_sets : int, optional
141
+ Number of three-phase sets, defaults to 2.
142
+ alpha_c : float, optional
143
+ Current-control bandwidth (rad/s), defaults to 2*pi*200.
144
+ alpha_i : float, optional
145
+ Integral-action bandwidth (rad/s), defaults to `alpha_c`. Not used in the
146
+ direct discrete-time design.
147
+ T_s : float, optional
148
+ Sampling period (s). If given, the direct discrete-time design is used instead
149
+ of the continuous-time design. The vector controller must then be a direct
150
+ discrete-time design as well (`k_comp = 0`), since the angle advance of the
151
+ voltage references is common to all modes, and its sampling period must equal
152
+ this value.
153
+
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ L_ls: float,
159
+ n_sets: int = 2,
160
+ alpha_c: float = 2 * pi * 200,
161
+ alpha_i: float | None = None,
162
+ T_s: float | None = None,
163
+ ) -> None:
164
+ self.L_ls = L_ls
165
+ self.n_sets = n_sets
166
+ self.T_s = T_s
167
+ self.discrete = T_s is not None
168
+ self.ctrls: list[ComplexPIController] | list[DiscreteComplexController]
169
+ if T_s is None:
170
+ alpha_i = alpha_c if alpha_i is None else alpha_i
171
+ k_p, k_i = alpha_c + alpha_i, alpha_c * alpha_i
172
+ self.ctrls = [
173
+ ComplexPIController(k_p, k_i, alpha_c) for _ in range(n_sets - 1)
174
+ ]
175
+ else:
176
+ self.ctrls = [
177
+ DiscreteComplexController(alpha_c, T_s) for _ in range(n_sets - 1)
178
+ ]
179
+ self.T = dms_matrix(n_sets)
180
+ self.c_dm = np.zeros(n_sets - 1) # DM current references per CM current
181
+
182
+ def set_sharing(self, k_share: Sharing) -> None:
183
+ """Set the current-sharing coefficients, summing up to the number of sets."""
184
+ k_share = np.asarray(k_share, dtype=float)
185
+ if not np.isclose(np.sum(k_share), self.n_sets):
186
+ raise ValueError(
187
+ "The sharing coefficients must sum up to the number of sets"
188
+ )
189
+ self.c_dm = self.T[1:] @ k_share
190
+
191
+ def compute_output(
192
+ self, i_cm: complex, i_dm: np.ndarray
193
+ ) -> tuple[np.ndarray, np.ndarray]:
194
+ """Compute the DM current references and voltage references."""
195
+ i_dm_ref = self.c_dm * i_cm
196
+ u_dm_ref = [
197
+ ctrl.compute_output(self.L_ls * complex(i_ref), self.L_ls * complex(i))
198
+ for ctrl, i_ref, i in zip(self.ctrls, i_dm_ref, i_dm, strict=True)
199
+ ]
200
+ return i_dm_ref, np.array(u_dm_ref, dtype=complex)
201
+
202
+ def update(self, T_s: float, dm: DifferentialModeSignals, w_c: float) -> None:
203
+ """Update the integral states using the realized or limited DM voltages."""
204
+ if self.T_s is not None and not isclose(T_s, self.T_s):
205
+ raise ValueError(
206
+ "The sampling period must equal the design value of the direct "
207
+ "discrete-time DM controller"
208
+ )
209
+ u_dm = dm.u_lim if self.discrete else dm.u
210
+ for ctrl, u in zip(self.ctrls, u_dm, strict=True):
211
+ ctrl.update(T_s, complex(u), w_c)
212
+
213
+
214
+ # %%
215
+ class MultiThreePhaseControlSystem(
216
+ ControlSystem[MultiThreePhaseDrive, Measurements, References, Feedbacks]
217
+ ):
218
+ """
219
+ Vector control system of multi-three-phase machine drives.
220
+
221
+ The control is based on the decoupled multi-stator (DMS) transformation, which maps
222
+ the three-phase sets to the common mode (CM) and the differential modes (DMs)
223
+ [#Rub2021]_. The CM is controlled by the given vector controller of a three-phase
224
+ machine, see :func:`~motulator_multiphase.utils.to_parallel`. Its feedback signals
225
+ are the CM voltage and the current of the equivalent machine, i.e., the sum of the
226
+ set currents. The DMs are controlled by the DM current controller in the
227
+ coordinates of the vector controller. Each set has its own PWM. As in
228
+ `VectorControlSystem`, the angles of the voltage references are advanced by
229
+ `k_comp*T_s*w_c`, where `k_comp` is given by the vector controller, and the speed
230
+ controller and the external references work in the same manner. The DM signals are
231
+ logged in the signal group `dm`, see :class:`DifferentialModeSignals`, with the
232
+ shape (N, n_sets - 1). In the post-processed time series, they are in the same
233
+ coordinates as the signals of the vector controller (e.g., in the estimated rotor
234
+ flux coordinates for induction machines).
235
+
236
+ Parameters
237
+ ----------
238
+ vector_ctrl : VectorController
239
+ Vector controller of the CM, configured with the parameters of the
240
+ parallel-connected equivalent machine.
241
+ dm_ctrl : DifferentialModeController
242
+ Current controller of the DMs.
243
+ speed_ctrl : SpeedController | PIController | None, optional
244
+ Speed controller. If None (default), torque-control mode is used.
245
+ theta_sets : Sequence[float], optional
246
+ Angles (rad) of the phases a of the sets, defaults to the asymmetrical
247
+ arrangement.
248
+ pwms : Sequence[PWM], optional
249
+ Pulse-width modulators of the sets, defaults to `PWM()` for each set.
250
+
251
+ References
252
+ ----------
253
+ .. [#Rub2021] Rubino, Dordevic, Armando, Bojoi, Levi, "A novel matrix transformation
254
+ for decoupled control of modular multiphase PMSM drives," IEEE Trans. Power
255
+ Electron., 2021, https://doi.org/10.1109/TPEL.2020.3043083
256
+
257
+ """
258
+
259
+ def __init__(
260
+ self,
261
+ vector_ctrl: VectorController,
262
+ dm_ctrl: DifferentialModeController,
263
+ speed_ctrl: SpeedController | PIController | None = None,
264
+ theta_sets: Sequence[float] | np.ndarray | None = None,
265
+ pwms: Sequence[PWM] | None = None,
266
+ ) -> None:
267
+ super().__init__()
268
+ n_sets = dm_ctrl.n_sets
269
+ if theta_sets is None:
270
+ theta_sets = default_set_angles(n_sets)
271
+ if len(theta_sets) != n_sets:
272
+ raise ValueError("The number of the set angles must equal n_sets")
273
+ if pwms is None:
274
+ pwms = [PWM() for _ in range(n_sets)]
275
+ if len(pwms) != n_sets:
276
+ raise ValueError("The number of the PWMs must equal n_sets")
277
+ if dm_ctrl.discrete and vector_ctrl.k_comp != 0:
278
+ raise ValueError(
279
+ "The direct discrete-time DM controller requires a direct "
280
+ "discrete-time vector controller (k_comp = 0)"
281
+ )
282
+ self.vector_ctrl = vector_ctrl
283
+ self.dm_ctrl = dm_ctrl
284
+ self.speed_ctrl = speed_ctrl
285
+ self.n_sets = n_sets
286
+ # Maps the set vectors in their own coordinates to the CM and the DMs
287
+ self.D = dms_matrix(n_sets) * np.exp(1j * np.asarray(theta_sets))
288
+ self.pwms = list(pwms)
289
+ self.k_comp = vector_ctrl.k_comp
290
+ self.ext_ref = ExternalReferences()
291
+ zeros = np.zeros(n_sets - 1, dtype=complex)
292
+ self.dm = DifferentialModeSignals(i=zeros, u=zeros)
293
+
294
+ def set_speed_ref(self, ref_fcn: float | Callable[[float], float]) -> None:
295
+ """
296
+ Set the external speed reference for speed-control mode.
297
+
298
+ Parameters
299
+ ----------
300
+ ref_fcn : float | Callable[[float], float]
301
+ Speed reference (mechanical rad/s), constant or a function of time.
302
+
303
+ """
304
+ if self.speed_ctrl is None:
305
+ raise ValueError("Speed-control mode requires speed_ctrl")
306
+ self.ext_ref.w_M = ref_fcn
307
+
308
+ def set_torque_ref(self, ref_fcn: float | Callable[[float], float]) -> None:
309
+ """
310
+ Set the external torque reference for torque-control mode.
311
+
312
+ Parameters
313
+ ----------
314
+ ref_fcn : float | Callable[[float], float]
315
+ Torque reference (Nm), constant or a function of time.
316
+
317
+ """
318
+ if self.speed_ctrl is not None:
319
+ raise ValueError("The speed controller gives the torque reference")
320
+ self.ext_ref.tau_M = ref_fcn
321
+
322
+ def set_current_sharing(
323
+ self, ref_fcn: Sharing | Callable[[float], Sharing]
324
+ ) -> None:
325
+ """
326
+ Set the current-sharing coefficients.
327
+
328
+ Parameters
329
+ ----------
330
+ ref_fcn : Sequence[float] | Callable[[float], Sequence[float]]
331
+ Coefficients of the sets, summing up to the number of sets, constant or a
332
+ function of time. Equal sharing, i.e., all coefficients equal to one, is
333
+ used by default.
334
+
335
+ """
336
+ self.ext_ref.k_share = ref_fcn
337
+
338
+ def get_measurement(self, mdl: MultiThreePhaseDrive) -> Measurements:
339
+ """Sample the sensors, the speed and position sensors if the drive has them."""
340
+ if mdl.machine.n_sets != self.n_sets:
341
+ raise ValueError(
342
+ "The drive model and control system must have equal n_sets"
343
+ )
344
+ sensors = mdl.sensors
345
+ i_c_ab = abc2complex(sensors.i_c.sample())
346
+ u_dc = sensors.u_dc.sample()
347
+ w_M = None if sensors.w_M is None else sensors.w_M.sample()
348
+ theta_M = None if sensors.theta_M is None else wrap(sensors.theta_M.sample())
349
+ return Measurements(i_c_ab, u_dc, w_M, theta_M)
350
+
351
+ def get_feedback(self, meas: Measurements) -> Feedbacks:
352
+ """Get the feedback signals."""
353
+ # The observers use the voltage of the ongoing sampling period, while the
354
+ # control laws use the voltage averaged over the previous and ongoing periods
355
+ u_c_ab = [
356
+ pwm.get_realized_voltage(i, meas.u_dc, average=False)
357
+ for pwm, i in zip(self.pwms, meas.i_c_ab, strict=True)
358
+ ]
359
+ u_zoh_cm = complex(self.D[0] @ u_c_ab)
360
+ u_c_ab = [
361
+ pwm.get_realized_voltage(i, meas.u_dc)
362
+ for pwm, i in zip(self.pwms, meas.i_c_ab, strict=True)
363
+ ]
364
+ u_modes = self.D @ u_c_ab
365
+ i_modes = self.D @ meas.i_c_ab
366
+ # Parallel-connected equivalent machine
367
+ fbk = self.vector_ctrl.get_feedback(
368
+ u_zoh_cm, complex(self.n_sets * i_modes[0]), meas.w_M, meas.theta_M
369
+ )
370
+ exp_mj_theta_c = exp(-1j * fbk.theta_c)
371
+ fbk.u_s = exp_mj_theta_c * complex(u_modes[0])
372
+ fbk.u_dc = meas.u_dc
373
+ self.dm = DifferentialModeSignals(
374
+ i=exp_mj_theta_c * i_modes[1:], u=exp_mj_theta_c * u_modes[1:]
375
+ )
376
+ return fbk
377
+
378
+ def compute_output(self, fbk: Feedbacks) -> References:
379
+ """Compute the references and the duty ratios of the sets."""
380
+ ext_ref = self.ext_ref
381
+ if self.speed_ctrl is not None: # Speed-control mode
382
+ if ext_ref.w_M is None:
383
+ raise ValueError("Speed reference must be set")
384
+ w_M_ref = get_value(ext_ref.w_M, self.t)
385
+ tau_M_ref = self.speed_ctrl.compute_output(w_M_ref, fbk.w_M)
386
+ else: # Torque-control mode
387
+ if ext_ref.tau_M is None:
388
+ raise ValueError("Torque reference must be set")
389
+ w_M_ref, tau_M_ref = None, get_value(ext_ref.tau_M, self.t)
390
+ ref = self.vector_ctrl.compute_output(tau_M_ref, fbk)
391
+ ref.w_M = w_M_ref
392
+
393
+ # DM current control
394
+ dm = self.dm
395
+ if ext_ref.k_share is not None:
396
+ self.dm_ctrl.set_sharing(get_value(ext_ref.k_share, self.t))
397
+ dm.i_ref, dm.u_ref = self.dm_ctrl.compute_output(fbk.i_s / self.n_sets, dm.i)
398
+
399
+ # Angle-advanced voltage references of the sets in their own coordinates
400
+ exp_j_theta_c = exp(1j * fbk.theta_c)
401
+ u_modes = exp_j_theta_c * np.array([ref.u_s, *dm.u_ref])
402
+ u_c_ab = self.n_sets * self.D.conj().T @ u_modes
403
+ u_c_ab *= exp(1j * self.k_comp * ref.T_s * fbk.w_c)
404
+ ref.d_abc = [
405
+ d
406
+ for pwm, u in zip(self.pwms, u_c_ab, strict=True)
407
+ for d in pwm(ref.T_s, complex(u), fbk.u_dc, fbk.w_c)
408
+ ]
409
+
410
+ # Limited voltages in the controller coordinates
411
+ u_lim_modes = self.D @ [pwm.limited_voltage for pwm in self.pwms]
412
+ u_lim_modes = u_lim_modes / exp_j_theta_c
413
+ ref.u_s_lim, dm.u_lim = complex(u_lim_modes[0]), u_lim_modes[1:]
414
+ return ref
415
+
416
+ def compute_standby_output(self, fbk: Feedbacks) -> References:
417
+ """Compute the controller output while the gates are blocked."""
418
+ # The multi-three-phase converter model does not support gate blocking. The
419
+ # message is a separate statement, so that the method is not documented as an
420
+ # abstract method.
421
+ msg = "Multi-three-phase control does not support gate blocking"
422
+ raise NotImplementedError(msg)
423
+
424
+ def get_command(self, ref: References) -> tuple[float, Sequence[float]]:
425
+ """Extract the sampling period and the duty ratios from the references."""
426
+ return ref.T_s, ref.d_abc
427
+
428
+ def save(self, t: float, **signal_groups: Any) -> None:
429
+ """Extend the base class method by saving the DM signals."""
430
+ super().save(t, dm=self.dm, **signal_groups)
431
+
432
+ def update(self, ref: References, fbk: Feedbacks) -> None:
433
+ """Update the controller states."""
434
+ super().update(ref, fbk)
435
+ self.vector_ctrl.update(ref, fbk)
436
+ if self.speed_ctrl is not None:
437
+ self.speed_ctrl.update(ref.T_s, ref.tau_M)
438
+ self.dm_ctrl.update(ref.T_s, self.dm, fbk.w_c)
439
+
440
+ def post_process(self) -> TimeSeries:
441
+ """Extend the post-process method."""
442
+ ts = super().post_process()
443
+ theta_c = np.copy(ts.fbk.theta_c)
444
+ self.vector_ctrl.post_process(ts)
445
+ # The vector controller may rotate its signals to other coordinates, e.g., the
446
+ # induction machine controllers to the estimated rotor flux coordinates, and
447
+ # then updates the angle fbk.theta_c. The DM signals are rotated in the same
448
+ # manner.
449
+ T = np.exp(-1j * (ts.fbk.theta_c - theta_c))[:, np.newaxis]
450
+ for name, value in vars(ts.dm).items():
451
+ setattr(ts.dm, name, T * value)
452
+ return ts