Source code for pydynamicestimator.devices.inverter_inner

# Created: 2026-06-05
# (c) Copyright 2025 ETH Zurich
#
# Licensed under the GNU General Public License v3.0;
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at:
#
#     https://www.gnu.org/licenses/gpl-3.0.en.html
#
# This software is distributed "AS IS", WITHOUT WARRANTY OF ANY KIND,
# express or implied. See the License for specific language governing
# permissions and limitations under the License.

"""Inverter inner-control strategies (the cascaded voltage/current loops).

The inner controller is the converter's fast actuator: it regulates the capacitor
voltage to the reference ``Vcd`` from the outer voltage loop and produces the
switching voltage ``Vsw`` that drives the filter. It is swapped as a *whole unit*
(cascaded PI + virtual impedance), never block-by-block -- the is-a-cluster
signal -- so it is a single strategy axis. See
``docs/inverter_modernization_design.md`` ยง4.3.

It owns the four PI integrator states (``xi_d``/``xi_q`` voltage loop,
``gamma_d``/``gamma_q`` current loop) and the controller / virtual-impedance gains.
It reads the filter capacitance/inductance (``host.Cf`` / ``host.Lf``) for the
cross-coupling decoupling terms -- a cross-strategy parameter read via the host,
exactly as the SG strategies read host parameters.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Dict, List, Set

import casadi as ca
import numpy as np

if TYPE_CHECKING:
    from pydynamicestimator.system import Dae


[docs] class InnerControl(ABC): """Abstract base class for inverter inner-control strategies. :meth:`fgcall` consumes the converter-frame internal quantities (from the host's Park transforms), the converter frequency ``omega_c`` and the voltage reference ``Vcd``, writes its PI integrator state equations into ``dae.f``, and returns the switching voltage ``(Vswd, Vswq)`` in the network frame (consumed by the filter strategy). """
[docs] @abstractmethod def states(self) -> List[str]: ...
[docs] def algebs(self) -> List[str]: return []
[docs] def algebs_units(self) -> Dict[str, str]: return {}
[docs] def algebs_noise(self) -> Dict[str, float]: return {}
[docs] def algebs_x0(self) -> Dict[str, float]: return {}
[docs] @abstractmethod def units(self) -> List[str]: ...
[docs] @abstractmethod def params(self) -> Dict[str, float]: ...
[docs] @abstractmethod def states_noise(self) -> Dict[str, float]: ...
[docs] @abstractmethod def states_init_error(self) -> Dict[str, float]: ...
[docs] @abstractmethod def x0(self) -> Dict[str, float]: ...
[docs] @abstractmethod def descriptions(self) -> Dict[str, str]: ...
[docs] @abstractmethod def fgcall(self, host, dae: Dae, internals, omega_c, delta_c): """Run the inner control. ``internals`` is a dict of the converter-frame quantities {"Vfd","Vfq","itd","itq","ifd","ifq"}; the voltage command is read via ``host.voltage_command(dae)``. Publishes the switching voltage on ``host.Vswd``/``host.Vswq`` (read by the filter via ``host.switching_voltage(dae)``) and the current references on ``host.ifd_ref``/``host.ifq_ref`` (via ``host.current_ref(dae)``); writes the integrator state equations into ``dae.f``.""" ...
[docs] def requires(self) -> Set[str]: """Plant capability tags this controller needs from the filter (checked at construction, warning on mismatch). Empty by default.""" return set()
[docs] def finit_sequential(self, host, dae: Dae, filt, omega_c) -> Dict[str, np.ndarray]: """Steady-state init of the control loop. ``filt`` is the filter strategy's init dict (op-point + Vsw); ``omega_c`` is the synchronizing frequency from the angle source. Returns the integrator states plus the frame angle ``delta_c`` and the voltage command ``Vcd`` -- handed by the host to the angle (the delta_c slot) and the voltage controller (which unpacks Vcd).""" raise NotImplementedError( f"{type(self).__name__} provides no sequential inner-control init; " f"implement finit_sequential() or set _init_method='joint' on the device." )
[docs] class Cascaded(InnerControl): """Cascaded voltage + current PI control with virtual impedance. Voltage loop (xi): regulates the capacitor voltage to ``Vcd`` (minus the virtual-impedance drop), producing the filter-current reference. Current loop (gamma): regulates the filter current, producing the switching voltage. With the cross-coupling decoupling and feed-forward terms. Byte-identical to the control ladder previously inlined in ``Inverter.fgcall``. """
[docs] def states(self) -> List[str]: return ["xi_d", "xi_q", "gamma_d", "gamma_q"]
[docs] def units(self) -> List[str]: return ["p.u.", "p.u.", "p.u.", "p.u."]
[docs] def params(self) -> Dict[str, float]: return { "Kpv": 0.59, "Kiv": 736, "Kffv": 0, "Kpc": 1.27, "Kic": 14.3, "Kffc": 0, "Rv": 0, "Lv": 0.2, }
[docs] def states_noise(self) -> Dict[str, float]: return {"xi_d": 1, "xi_q": 1, "gamma_d": 1, "gamma_q": 1}
[docs] def states_init_error(self) -> Dict[str, float]: return {"xi_d": 1e-1, "xi_q": 1e-1, "gamma_d": 1e-1, "gamma_q": 1e-1}
[docs] def x0(self) -> Dict[str, float]: return {"xi_d": 0, "xi_q": 0, "gamma_d": 0, "gamma_q": 0}
[docs] def descriptions(self) -> Dict[str, str]: return { "Kpv": "Proportional gain for voltage controller", "Kiv": "Integral gain for voltage controller", "Kffv": "Feed-forward gain of voltage controller", "Kpc": "Proportional gain for current controller", "Kic": "Integral gain for current controller", "Kffc": "Feed-forward gain of current controller", "Rv": "Virtual impedance resistance", "Lv": "Virtual impedance inductance", "xi_d": "Integrator state of the d-component of the internal voltage", "xi_q": "Integrator state of the q-component of the internal voltage", "gamma_d": "Integrator state of the d-component of the internal current", "gamma_q": "Integrator state of the q-component of the internal current", }
[docs] def fgcall(self, host, dae: Dae, internals, omega_c, delta_c): Vfd_int = internals["Vfd"] Vfq_int = internals["Vfq"] itd_int = internals["itd"] itq_int = internals["itq"] ifd_int = internals["ifd"] ifq_int = internals["ifq"] # Voltage-magnitude command from the outer loop, host-mediated. Vcd = host.voltage_command(dae) # Vectorized over devices; every per-i expression below is preserved # verbatim (same operand grouping), so this is byte-identical. # Voltage controller references (with virtual impedance drop). Vfd_ref = Vcd - host.Rv * itd_int + omega_c * host.Lv * itq_int Vfq_ref = -host.Rv * itq_int - omega_c * host.Lv * itd_int # Current controller references (voltage PI + decoupling + feed-forward). ifd_ref = ( host.Kpv * (Vfd_ref - Vfd_int) + host.Kiv * dae.x[host.xi_d] - omega_c * host.Cf * Vfq_int + host.Kffv * itd_int ) ifq_ref = ( host.Kpv * (Vfq_ref - Vfq_int) + host.Kiv * dae.x[host.xi_q] + omega_c * host.Cf * Vfd_int + host.Kffv * itq_int ) # Publish the current references and read them back through the host # accessor. This is the limiter interception seam: a current limiter (b1) # makes host.current_ref resolve to a saturated var_sym algebraic # (i* = sat(iref)); with no limiter the accessor returns these very # expressions, so the current loop below is byte-identical. host.ifd_ref, host.ifq_ref = ifd_ref, ifq_ref ifd_ref, ifq_ref = host.current_ref(dae) # Switching-voltage references (current PI + decoupling + feed-forward). Vswd_ref = ( host.Kpc * (ifd_ref - ifd_int) + host.Kic * dae.x[host.gamma_d] - omega_c * host.Lf * ifq_int + host.Kffc * Vfd_int ) Vswq_ref = ( host.Kpc * (ifq_ref - ifq_int) + host.Kic * dae.x[host.gamma_q] + omega_c * host.Lf * ifd_int + host.Kffc * Vfq_int ) # Rotate the switching voltage back to the network (external) frame and # publish it on the host (read by the filter via host.switching_voltage). host.Vswd, host.Vswq = host.to_external(Vswd_ref, Vswq_ref, delta_c) # PI integrator dynamics. dae.f[host.xi_d] = Vfd_ref - Vfd_int dae.f[host.xi_q] = Vfq_ref - Vfq_int dae.f[host.gamma_d] = ifd_ref - ifd_int dae.f[host.gamma_q] = ifq_ref - ifq_int
[docs] def requires(self) -> Set[str]: # The cascaded voltage loop regulates the capacitor voltage and feeds Cf # forward, so it presupposes a shunt-capacitor (LC/LCL) plant. return {"shunt_capacitor"}
[docs] def finit_sequential(self, host, dae: Dae, filt, omega_c) -> Dict[str, np.ndarray]: """Steady-state of the cascaded control ladder. Solves the 6 conditions (the four PI integrators steady + the two switching-voltage matches) for the frame angle ``delta_c``, the voltage command ``Vcd`` and the four integrators. ``Pc_tilde/Qc_tilde`` are NOT solved here -- they are the frame-invariant powers, computed by the host from the filter op-point. This is the first six (decoupled) rows of the previous 8x8 inner_loop_init, so the roots are bit-identical.""" n = host.n n_unknowns = 6 delta_c = ca.SX.sym("delta_c", n) Vcd = ca.SX.sym("Vcd", n) xi_d = ca.SX.sym("xi_d", n) xi_q = ca.SX.sym("xi_q", n) gamma_d = ca.SX.sym("gamma_d", n) gamma_q = ca.SX.sym("gamma_q", n) Vfd_ext, Vfq_ext = filt["Vfd_ext"], filt["Vfq_ext"] ifd_ext, ifq_ext = filt["ifd_ext"], filt["ifq_ext"] itd_ext, itq_ext = filt["itd_ext"], filt["itq_ext"] Vswd, Vswq = filt["Vswd"], filt["Vswq"] # Vectorized over devices; per-i expressions preserved verbatim, so the # Newton residual graph -- and hence the root it converges to -- is # byte-identical to the previous per-element build. itd_int, itq_int = host.to_internal(itd_ext, itq_ext, delta_c) ifd_int, ifq_int = host.to_internal(ifd_ext, ifq_ext, delta_c) Vfd_int, Vfq_int = host.to_internal(Vfd_ext, Vfq_ext, delta_c) Vswd_int, Vswq_int = host.to_internal(Vswd, Vswq, delta_c) Vfd_ref = Vcd - host.Rv * itd_int + omega_c * host.Lv * itq_int Vfq_ref = -host.Rv * itq_int - omega_c * host.Lv * itd_int ifd_ref = ( host.Kpv * (Vfd_ref - Vfd_int) + host.Kiv * xi_d - omega_c * host.Cf * Vfq_int + host.Kffv * itd_int ) ifq_ref = ( host.Kpv * (Vfq_ref - Vfq_int) + host.Kiv * xi_q + omega_c * host.Cf * Vfd_int + host.Kffv * itq_int ) Vswd_ref = ( host.Kpc * (ifd_ref - ifd_int) + host.Kic * gamma_d - omega_c * host.Lf * ifq_int + host.Kffc * Vfd_int ) Vswq_ref = ( host.Kpc * (ifq_ref - ifq_int) + host.Kic * gamma_q + omega_c * host.Lf * ifd_int + host.Kffc * Vfq_int ) outputs = ca.SX(np.zeros(n_unknowns * n)) outputs[0:n] = Vfd_ref - Vfd_int outputs[n : 2 * n] = Vfq_ref - Vfq_int outputs[2 * n : 3 * n] = ifd_ref - ifd_int outputs[3 * n : 4 * n] = ifq_ref - ifq_int outputs[4 * n : 5 * n] = Vswd_ref - Vswd_int outputs[5 * n : 6 * n] = Vswq_ref - Vswq_int h = ca.Function( "h", [ca.vertcat(delta_c, Vcd, xi_d, xi_q, gamma_d, gamma_q)], [ca.vertcat(outputs)], ) G = ca.rootfinder("G", "newton", h) sol = np.array(G(ca.vertcat(np.zeros(n_unknowns * n)))).flatten() return { "delta_c": sol[0:n], "Vcd": sol[n : 2 * n], "xi_d": sol[2 * n : 3 * n], "xi_q": sol[3 * n : 4 * n], "gamma_d": sol[4 * n : 5 * n], "gamma_q": sol[5 * n : 6 * n], }
INNER_REGISTRY: Dict[str, type] = { "Cascaded": Cascaded, }