Source code for motulator_multiphase.model

"""
Continuous-time models for multi-three-phase machine drives.

Peak-valued complex space vectors are used. The voltages and currents of the
three-phase sets are space vectors in the sets' own coordinates. The mechanical
system models, the machine parameter classes, the modulators, and the simulation
class of motulator are re-exported for convenience.

"""

from collections.abc import Callable, Sequence
from dataclasses import InitVar, dataclass, field
from types import SimpleNamespace
from typing import Any

import numpy as np
from motulator.common.model import (
    ControlledConverter,
    Converter,
    Model,
    Subsystem,
    SubsystemTimeSeries,
    TwoLevelModulator,
)
from motulator.common.utils import abc2complex, complex2abc
from motulator.drive.model import (
    ZOH,
    CarrierComparison,
    DriveSensors,
    ExternalRotorSpeed,
    InductionMachine,
    InductionMachineInvGammaPars,
    InductionMachinePars,
    MechanicalSystem,
    SaturatedSynchronousMachinePars,
    Sensor,
    Simulation,
    SpatialSaturatedSynchronousMachinePars,
    SynchronousMachine,
    SynchronousMachinePars,
    TwoMassMechanicalSystem,
)

from motulator_multiphase._helpers import empty_array
from motulator_multiphase.utils import (
    MachinePars,
    default_set_angles,
    dms_matrix,
    to_parallel,
)

__all__ = [
    "ZOH",
    "CarrierComparison",
    "DriveSensors",
    "ExternalRotorSpeed",
    "InductionMachineInvGammaPars",
    "InductionMachinePars",
    "MechanicalSystem",
    "MultiThreePhaseConverter",
    "MultiThreePhaseDrive",
    "MultiThreePhaseMachine",
    "SaturatedSynchronousMachinePars",
    "Sensor",
    "Simulation",
    "SpatialSaturatedSynchronousMachinePars",
    "SynchronousMachinePars",
    "TwoMassMechanicalSystem",
]


# %%
@dataclass
class ConverterInputs:
    """Input variables."""

    q_c_ab: np.ndarray  # Switching states of the sets, shape (n_sets,)
    s_c_ab: np.ndarray  # Current directions at the latest switching instant
    i_c_ab: np.ndarray  # Currents of the sets, shape (n_sets,)
    enabled: bool = True  # Gate enable, always True (blocking is not supported)


@dataclass
class ConverterOutputs:
    """Output variables for interconnection."""

    u_c_ab: np.ndarray  # Voltages of the sets, shape (n_sets,)
    u_dc: float


[docs] class MultiThreePhaseConverter(Converter, ControlledConverter): """ Lossless multi-three-phase converter with constant DC-bus voltage. The converter consists of `n_sets` three-phase converters sharing the DC bus. The switching states, voltages, and currents of each set are space vectors in the set's own coordinates. The duty ratios and the gate states of the legs are ordered set by set, `[a1, b1, c1, a2, ...]`. The modulator of *motulator* converts the duty ratios into the gate states, including the computational delay and the dead time. As in the three-phase converter, the switches are ideal except for the dead time: when a leg is blanked, its state is `(1 - sign(i))/2`. Gate blocking is not supported. Parameters ---------- u_dc : float DC-bus voltage (V). n_sets : int, optional Number of three-phase sets, defaults to 2. modulator : TwoLevelModulator, optional Modulator of the legs, e.g., `ZOH` or `CarrierComparison` of *motulator*. Defaults to `ZOH()`, i.e., the switching-cycle averaged model without the dead time and with the computational delay of one sampling period. sign : Callable[[np.ndarray], np.ndarray], optional Function of the phase currents (A) determining the leg states during blanking, defaults to `np.sign`. """ def __init__( self, u_dc: float, n_sets: int = 2, modulator: TwoLevelModulator[Any] | None = None, sign: Callable[[np.ndarray], np.ndarray] = np.sign, ) -> None: self.u_dc = u_dc self.n_sets = n_sets self.modulator = ZOH() if modulator is None else modulator self.sign = sign self.inp: ConverterInputs = ConverterInputs( q_c_ab=np.zeros(n_sets, dtype=complex), s_c_ab=np.zeros(n_sets, dtype=complex), i_c_ab=np.zeros(n_sets, dtype=complex), ) self.out: ConverterOutputs = ConverterOutputs( u_c_ab=np.zeros(n_sets, dtype=complex), u_dc=u_dc ) self.state = None
[docs] def modulate(self, T_s: float, command: Any) -> tuple[np.ndarray, np.ndarray]: """ Convert the duty ratios to the gate states and their durations. Parameters ---------- T_s : float Sampling period (s). command : Any Duty ratios of the legs, shape (3*n_sets,). Returns ------- t_steps : ndarray, shape (N,) Durations of the intervals (s), summing to `T_s`. gates : ndarray, shape (N, 2, 3*n_sets) Gate signals of the upper switches and blanking signals in each interval. """ return self.modulator(T_s, command)
[docs] def set_gates(self, gates: np.ndarray) -> None: """Set the switching states based on the gate state of an interval.""" q_abc, b_abc = gates s_abc = self.sign(complex2abc(self.inp.i_c_ab).T.ravel()) q_abc = q_abc + 0.5 * b_abc * (1 - s_abc) self.inp.q_c_ab = abc2complex(np.reshape(q_abc, (self.n_sets, 3)).T) self.inp.s_c_ab = abc2complex(np.reshape(s_abc, (self.n_sets, 3)).T)
[docs] def set_outputs(self, t: float) -> None: """Set output variables.""" self.out.u_c_ab = self.inp.q_c_ab * self.out.u_dc
[docs] def rhs(self, t: float) -> list[complex]: """No states.""" return []
[docs] def init_state_history(self) -> None: """Initialize the history of the switching states.""" super().init_state_history() self._history |= {"q_c_ab": [], "s_c_ab": [], "enabled": []}
[docs] def extend_history(self, n: int, u_c_ab: Sequence[Any] | None) -> None: """Extend the history, the switching states held constant over `n` points.""" # The sets of each point follow each other self._history["q_c_ab"].extend(list(self.inp.q_c_ab) * n) self._history["s_c_ab"].extend(list(self.inp.s_c_ab) * n) self._history["enabled"].extend([self.inp.enabled] * n)
[docs] def create_time_series(self, t: np.ndarray) -> "MultiThreePhaseConverterTimeSeries": """Create time series.""" return MultiThreePhaseConverterTimeSeries(t, self)
@dataclass class MultiThreePhaseConverterTimeSeries(SubsystemTimeSeries): """Continuous time series, the set quantities of shape (n_sets, N).""" t: InitVar[np.ndarray] subsystem: InitVar[MultiThreePhaseConverter] u_dc: np.ndarray = field(default_factory=empty_array) q_c_ab: np.ndarray = field(default_factory=empty_array) s_c_ab: np.ndarray = field(default_factory=empty_array) enabled: np.ndarray = field(default_factory=empty_array) i_c_ab: np.ndarray = field(default_factory=empty_array) u_c_ab: np.ndarray = field(default_factory=empty_array) def __post_init__(self, t: np.ndarray, subsystem: MultiThreePhaseConverter) -> None: self.u_dc = np.full(np.size(t), subsystem.u_dc) self.q_c_ab = np.array(subsystem._history["q_c_ab"]) self.s_c_ab = np.array(subsystem._history["s_c_ab"]) self.enabled = np.array(subsystem._history["enabled"]) def set_outputs(self, t: np.ndarray, subsystem: MultiThreePhaseConverter) -> None: """Compute the set voltages.""" # The history has the sets of each point in sequence self.q_c_ab = np.reshape(self.q_c_ab, (-1, subsystem.n_sets)).T self.s_c_ab = np.reshape(self.s_c_ab, (-1, subsystem.n_sets)).T self.u_c_ab = self.q_c_ab * self.u_dc # %% @dataclass class MachineInputs: """Input variables.""" u_c_ab: np.ndarray # Voltages of the sets, shape (n_sets,) w_M: float = 0.0 @dataclass class MachineOutputs: """Output variables for interconnection.""" i_c_ab: np.ndarray # Currents of the sets, shape (n_sets,) tau_M: float
[docs] class MultiThreePhaseMachine(Subsystem): """ Multi-three-phase machine model. The machine has `n_sets` three-phase winding sets. The model is based on the decoupled multi-stator (DMS) transformation, which maps the sets to the common mode (CM) and the differential modes (DMs) [#Rub2021]_, see :func:`~motulator_multiphase.utils.dms_matrix`. The CM is equivalent to a three-phase machine whose sets are connected in parallel. It is modeled by the three-phase machine model `cm` (`SynchronousMachine` or `InductionMachine`) with the parameters given by :func:`~motulator_multiphase.utils.to_parallel`. Each DM is a resistive-inductive circuit, consisting of the stator resistance and the leakage inductance `L_ls` of a set, which is not coupled to the other sets. The DMs produce no torque, and they are modeled in stationary coordinates. The states are those of the CM machine, followed by the DM flux linkages. Parameters ---------- par : MachinePars Parameters of one three-phase set, when all sets carry equal currents. L_ls : float Leakage inductance (H) of a set, which is not coupled to the other sets. n_sets : int, optional Number of three-phase sets, defaults to 2. theta_sets : Sequence[float], optional Angles (rad) of the phases a of the sets, defaults to the asymmetrical arrangement, see :func:`~motulator_multiphase.utils.default_set_angles`. Notes ----- The resistance of the DMs is the stator resistance `par.R_s` of a set, which is the same in the Γ and inverse-Γ models of the induction machine. References ---------- .. [#Rub2021] Rubino, Dordevic, Armando, Bojoi, Levi, "A novel matrix transformation for decoupled control of modular multiphase PMSM drives," IEEE Trans. Power Electron., 2021, https://doi.org/10.1109/TPEL.2020.3043083 """ # The currents depend directly on the voltages through the core losses feedthrough = frozenset({"u_c_ab"}) def __init__( self, par: MachinePars, L_ls: float, n_sets: int = 2, theta_sets: Sequence[float] | np.ndarray | None = None, ) -> None: if theta_sets is None: theta_sets = default_set_angles(n_sets) if len(theta_sets) != n_sets: raise ValueError("The number of the set angles must equal n_sets") self.n_sets = n_sets self.theta_sets = np.asarray(theta_sets, dtype=float) self.R_s = par.R_s self.L_ls = L_ls # Maps the set vectors in their own coordinates to the CM and the DMs self.D = dms_matrix(n_sets) * np.exp(1j * self.theta_sets) par_cm = to_parallel(par, n_sets) self.cm: SynchronousMachine | InductionMachine if isinstance(par_cm, (InductionMachinePars, InductionMachineInvGammaPars)): self.cm = InductionMachine(par_cm) else: self.cm = SynchronousMachine(par_cm) self._dm_fields = [f"psi_dm{u}_ab" for u in range(1, n_sets)] self.state = SimpleNamespace( **vars(self.cm.state), **dict.fromkeys(self._dm_fields, 0j) ) self.inp: MachineInputs = MachineInputs(u_c_ab=np.zeros(n_sets, dtype=complex)) self.out: MachineOutputs = MachineOutputs( i_c_ab=np.zeros(n_sets, dtype=complex), tau_M=0.0 ) self.set_outputs(0.0) @property
[docs] def psi_dm_ab(self) -> np.ndarray: """DM flux linkages (Vs), shape (n_sets - 1,).""" return np.array( [getattr(self.state, f) for f in self._dm_fields], dtype=complex )
[docs] def set_states(self, state_list: list[complex], index: int) -> int: """Set the states, also those of the CM machine.""" self.cm.set_states(state_list, index) index = super().set_states(state_list, index) # The CM machine wraps its rotor angle, and the next integration interval # starts from the wrapped angle for name, value in vars(self.cm.state).items(): setattr(self.state, name, value) return index
[docs] def init_state_history(self) -> None: """Initialize the state history, the CM states in the CM machine.""" self.cm.init_state_history() self._history = {f: [] for f in self._dm_fields}
[docs] def extend_state_history(self, sol_y: list[list[float]], index: int) -> int: """Extend the state history, the CM states in the CM machine.""" index = self.cm.extend_state_history(sol_y, index) for name in self._dm_fields: self._history[name].extend(sol_y[index]) index += 1 return index
[docs] def compute_currents(self, i_s_ab: Any, psi_dm_ab: Any) -> np.ndarray: """ Compute the currents of the sets. Parameters ---------- i_s_ab : complex | ndarray, shape (N,) Current (A) of the parallel-connected machine, i.e., `n_sets` times the CM. psi_dm_ab : ndarray, shape (n_sets - 1,) or (n_sets - 1, N) DM flux linkages (Vs). Returns ------- ndarray, shape (n_sets,) or (n_sets, N) Currents (A) of the sets in their own coordinates. """ i_modes = np.array([i_s_ab / self.n_sets, *(psi_dm_ab / self.L_ls)]) return self.n_sets * self.D.conj().T @ i_modes
def _set_cm_inputs(self) -> None: """Pass the CM voltage and the speed to the CM machine.""" self.cm.inp.u_s_ab = complex(self.D[0] @ self.inp.u_c_ab) self.cm.inp.w_M = self.inp.w_M
[docs] def set_outputs(self, t: float) -> None: """Set output variables.""" self._set_cm_inputs() self.cm.set_outputs(t) self.out.i_c_ab = self.compute_currents(self.cm.out.i_s_ab, self.psi_dm_ab) self.out.tau_M = self.cm.out.tau_M
[docs] def rhs(self, t: float) -> list[complex]: """Compute state derivatives.""" # The mechanics may have updated the speed after set_outputs self._set_cm_inputs() u_dm_ab = self.D[1:] @ self.inp.u_c_ab d_psi_dm_ab = u_dm_ab - self.R_s * self.psi_dm_ab / self.L_ls return [*self.cm.rhs(t), *d_psi_dm_ab]
[docs] def create_time_series(self, t: np.ndarray) -> "MultiThreePhaseMachineTimeSeries": """Create time series.""" return MultiThreePhaseMachineTimeSeries(t, self)
@dataclass class MultiThreePhaseMachineTimeSeries(SubsystemTimeSeries): """ Continuous time series of the multi-three-phase machine. The time series of the CM machine is `cm`. The stator flux linkage, current, and voltage of the parallel-connected machine are also given as `psi_s_ab`, `i_s_ab`, and `u_s_ab`, so that the plotting functions of three-phase drives can be used. The set quantities have the shape (n_sets, N) and the DM quantities (n_sets - 1, N). """ t: InitVar[np.ndarray] subsystem: InitVar[MultiThreePhaseMachine] cm: Any = field(init=False) # Time series of the CM machine # States psi_dm_ab: np.ndarray = field(default_factory=empty_array) # Outputs i_c_ab: np.ndarray = field(default_factory=empty_array) tau_M: np.ndarray = field(default_factory=empty_array) # Inputs u_c_ab: np.ndarray = field(default_factory=empty_array) w_M: np.ndarray = field(default_factory=empty_array) # Derived signals i_dm_ab: np.ndarray = field(default_factory=empty_array) psi_s_ab: np.ndarray = field(default_factory=empty_array) i_s_ab: np.ndarray = field(default_factory=empty_array) u_s_ab: np.ndarray = field(default_factory=empty_array) def __post_init__(self, t: np.ndarray, subsystem: MultiThreePhaseMachine) -> None: self.cm = subsystem.cm.create_time_series(t) self.psi_dm_ab = np.reshape( [subsystem._history[f] for f in subsystem._dm_fields], (subsystem.n_sets - 1, np.size(t)), ) self.i_dm_ab = self.psi_dm_ab / subsystem.L_ls self.psi_s_ab = self.cm.psi_s_ab def set_outputs(self, t: np.ndarray, subsystem: MultiThreePhaseMachine) -> None: """Compute the outputs, which depend on the voltages due to core losses.""" self.u_s_ab = subsystem.D[0] @ self.u_c_ab self.cm.u_s_ab = self.u_s_ab self.cm.set_outputs(t, subsystem.cm) self.i_s_ab, self.tau_M = self.cm.i_s_ab, self.cm.tau_M self.i_c_ab = subsystem.compute_currents(self.i_s_ab, self.psi_dm_ab) def compute_derived_signals( self, t: np.ndarray, subsystem: MultiThreePhaseMachine ) -> None: """Pass the speed to the CM machine and compute its derived signals.""" self.cm.w_M = self.w_M self.cm.compute_derived_signals(t, subsystem.cm) # %%
[docs] class MultiThreePhaseDrive(Model[MultiThreePhaseConverter]): """ Continuous-time model of a multi-three-phase machine drive. Parameters ---------- machine : MultiThreePhaseMachine Machine model. mechanics : MechanicalSystem | TwoMassMechanicalSystem | ExternalRotorSpeed Mechanical system model. converter : MultiThreePhaseConverter Converter model. sensors : DriveSensors, optional Sensors, see `DriveSensors`, defaults to ideal phase-current and DC-bus voltage sensors. Sensored control also needs the speed and position sensors. The current sensors measure the phase currents of all the sets, shape (3, n_sets), and cannot have a bandwidth. """ def __init__( self, machine: MultiThreePhaseMachine, mechanics: MechanicalSystem | TwoMassMechanicalSystem | ExternalRotorSpeed, converter: MultiThreePhaseConverter, sensors: DriveSensors | None = None, ) -> None: if machine.n_sets != converter.n_sets: raise ValueError("The machine and converter must have equal n_sets") self.machine = machine self.mechanics = mechanics self.sensors = DriveSensors() if sensors is None else sensors if self.sensors.i_c.state is not None: raise ValueError("The current sensors cannot have a bandwidth") subsystems: dict[str, Subsystem] = { "converter": converter, "machine": machine, "mechanics": mechanics, "i_c_sensor": self.sensors.i_c, "u_dc_sensor": self.sensors.u_dc, } connections = { ("machine", "u_c_ab"): ("converter", "u_c_ab"), ("machine", "w_M"): ("mechanics", "w_M"), ("converter", "i_c_ab"): ("machine", "i_c_ab"), ("mechanics", "tau_M"): ("machine", "tau_M"), ("i_c_sensor", "x"): ("machine", "i_c_ab"), ("u_dc_sensor", "x"): ("converter", "u_dc"), } if self.sensors.w_M is not None: subsystems["w_M_sensor"] = self.sensors.w_M connections[("w_M_sensor", "x")] = ("mechanics", "w_M") if self.sensors.theta_M is not None: if self.sensors.theta_M.state is not None: raise ValueError("The position sensor cannot have a bandwidth") subsystems["theta_M_sensor"] = self.sensors.theta_M connections[("theta_M_sensor", "x")] = ("mechanics", "theta_M") super().__init__(converter, subsystems, connections)