Source code for pydynamicestimator.devices.inverter_angle

# 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 angle-source strategies (what makes a converter forming vs following).

The angle source produces the converter frequency ``omega_c`` and integrates the
converter-frame angle ``delta_c`` -- it fuses the synchronous machine's governor
*and* shaft roles (there is no separable mechanical-power intermediate in an
inverter), so the active-power droop and the power-measurement state ``Pc_tilde``
live here. It is the **mandatory** axis: a grid-forming converter sets its own
frequency from the droop off nominal (``DroopAngle``, exposing ``host.omega_c``);
a grid-following converter rides on a PLL's frequency (``PLLAngle``, reading
``host.pll_frequency``). Future variants: VSM (swing ODE), dVOC, matching control.

See ``docs/inverter_modernization_design.md`` ยง4.1. NOTE (Phase 2 boundary): the
angle source owns the ``Pc_tilde`` *state* and the droop, but the first-order
power-measurement filter equation ``d Pc_tilde/dt = omega_f (Pc - Pc_tilde)`` is
written by the host, because ``Pc`` is computed from the shared Park-transform loop
in ``Inverter.fgcall``. A later cleanup can move power computation behind a host
accessor and the filter equation into the strategy.
"""

from __future__ import annotations

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

import casadi as ca
import numpy as np

if TYPE_CHECKING:
    from pydynamicestimator.system import Dae


[docs] class AngleSource(ABC): """Abstract base class for inverter angle-source strategies (pluggable). Must own ``delta_c`` (and, for the droop family, ``Pc_tilde``) and expose the converter frequency: :meth:`fgcall` returns the ``omega_c`` vector consumed by the host's inner control ladder and writes ``dae.f[delta_c]``. The strategy reads host params/states/setpoints by attribute (``host.Kp``, ``host.Pref``, ``dae.x[host.Pc_tilde]``) and, for the following variant, the synchronizing frequency via ``host.pll_frequency(dae)``. """
[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 setpoints(self) -> Dict[str, float]: ...
[docs] @abstractmethod def fgcall(self, host, dae: Dae, omega_ref_vec, omega_b): """Return the ``omega_c`` vector and write ``dae.f[delta_c]`` (the angle dynamics). Grid-forming variants also set ``host.omega_c``; grid-following variants leave it unset and read ``host.pll_frequency(dae)`` instead.""" ...
[docs] def steady_frequency(self, host, dae: Dae) -> np.ndarray: """The converter frequency at steady state, fed to the inner-control init's decoupling terms. Nominal for ANY synchronizing source: ``delta_c`` steady forces ``omega_c = omega_ref = omega_net``, independent of the angle law.""" return dae.omega_net * np.ones(host.n)
[docs] def finit_sequential( self, host, dae: Dae, Pc: np.ndarray, delta_c: np.ndarray ) -> Dict[str, np.ndarray]: """Resolve the angle source's states/setpoints from the (frame-invariant) active power ``Pc`` and the inner-controller's frame angle ``delta_c``. The base raises so a new angle source declares its own.""" raise NotImplementedError( f"{type(self).__name__} provides no sequential angle init; implement " f"finit_sequential() or set _init_method='joint' on the device." )
[docs] class _PowerDroopAngle(AngleSource): """Shared declarations for the power-droop angle family (``Pc_tilde`` + ``delta_c`` states, ``Kp`` droop gain, ``Pref`` setpoint). Concrete subclasses differ only in the frequency anchor inside :meth:`fgcall`."""
[docs] def states(self) -> List[str]: return ["Pc_tilde", "delta_c"]
[docs] def units(self) -> List[str]: return ["p.u.", "rad"]
[docs] def params(self) -> Dict[str, float]: return {"Kp": 0.02}
[docs] def states_noise(self) -> Dict[str, float]: return {"Pc_tilde": 1, "delta_c": 1}
[docs] def states_init_error(self) -> Dict[str, float]: return {"Pc_tilde": 1e-1, "delta_c": 1e-1}
[docs] def x0(self) -> Dict[str, float]: return {"Pc_tilde": 0.1, "delta_c": 0}
[docs] def setpoints(self) -> Dict[str, float]: return {"Pref": 0.5}
[docs] def descriptions(self) -> Dict[str, str]: return { "Kp": "Droop coefficient for P-f", "Pref": "Active power set point", "Pc_tilde": "Filtered internal active power", "delta_c": "Converter-frame angle relative to the network", }
[docs] def finit_sequential( self, host, dae: Dae, Pc: np.ndarray, delta_c: np.ndarray ) -> Dict[str, np.ndarray]: # Synchronization fixes Pref = Pc (omega_c = omega_net => the droop term is # zero); the power-measurement filter settles at Pc_tilde = Pc. delta_c is # the inner controller's frame-alignment result, merely owned here. return {"Pc_tilde": Pc, "delta_c": delta_c, "Pref": Pc}
[docs] class DroopAngle(_PowerDroopAngle): """Grid-forming droop: the converter sets its own frequency from the active-power droop off nominal, ``omega_c = omega_net + Kp (Pref - Pc_tilde)``, and exposes it as ``host.omega_c`` (read by the COI / single reference-frame machinery in ``system.py``). Byte-identical to the previous GridForming angle. """
[docs] def fgcall(self, host, dae: Dae, omega_ref_vec, omega_b): # Vectorized over devices (was a per-element loop). The per-i expression # omega_net + Kp*(Pref - Pc_tilde) is preserved verbatim, so this is # byte-identical -- a single-product term has no reassociation freedom. host.omega_c = dae.omega_net + host.Kp * (host.Pref - dae.x[host.Pc_tilde]) delta_omega_c = host.omega_c - omega_ref_vec dae.f[host.delta_c] = omega_b * delta_omega_c return host.omega_c
[docs] class PLLAngle(_PowerDroopAngle): """Grid-following: the converter frequency rides on the PLL's synchronizing frequency (read host-mediated via ``host.pll_frequency``) plus the active-power droop, ``omega_c = omega_pll + Kp (Pref - Pc_tilde)``. Pairs with a PLL strategy (which owns ``omega_pll`` and the PLL states). Byte-identical to the previous GridFollowing angle. """
[docs] def fgcall(self, host, dae: Dae, omega_ref_vec, omega_b): # Vectorized over devices; per-i expression preserved -> byte-identical. omega_pll = host.pll_frequency(dae) omega_c = omega_pll + host.Kp * (host.Pref - dae.x[host.Pc_tilde]) delta_omega_c = omega_c - omega_ref_vec dae.f[host.delta_c] = omega_b * delta_omega_c return omega_c
ANGLE_REGISTRY: Dict[str, type] = { "Droop": DroopAngle, "PLL": PLLAngle, }