# 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,
}