Source code for pydynamicestimator.devices.inverter_pll

# 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 phase-locked-loop (frequency/phase estimator) strategies.

The PLL is a measurement strategy, **separate from the angle source** -- the
inverter analogue of the synchronous machine's PSS: it produces a signal
(``omega_pll``) consumed by *another* strategy (the angle source), host-mediated
via ``host.pll_frequency(dae)``, and never references its consumer. Keeping it a
distinct axis is what lets a grid-forming converter carry a PLL for FRT/monitoring
without baking it into "grid-following" (see
``docs/inverter_modernization_design.md`` ยง4.2). ``pll=None`` (the default) means
no PLL block.

It owns its integrator/angle states (``epsilon`` / ``delta_pll``), registered LAST
in the state vector so that -- with the angle source owning ``Pc_tilde`` /
``delta_c`` near the front -- the original GridFollowing ordering is reproduced
byte-for-byte.
"""

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 PLL(ABC): """Abstract base class for inverter PLL (frequency estimator) strategies. Like the SG strategies, the PLL does NOT own state arrays or DAE indices; it declares them and the host ``Inverter`` registers them on itself. Its :meth:`fgcall` writes its own state equations into ``dae.f`` and publishes the estimated synchronizing frequency on ``host.omega_pll`` (an algebraic expression, not a registered variable), which the host exposes to consumers via ``host.pll_frequency(dae)`` and which the reference-frame machinery in ``system.py`` reads for grid-following devices. """
[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, omega_ref_vec, omega_b) -> None: """Write the PLL state equations into ``dae.f`` and set ``host.omega_pll`` to the estimated synchronizing frequency (read by the angle source via ``host.pll_frequency(dae)``).""" ...
[docs] def finit_sequential(self, host, dae: Dae, Vfd_ext, Vfq_ext) -> Dict[str, np.ndarray]: """Steady-state init of the PLL from the filter voltage (decoupled). Returns its state values (e.g. ``epsilon``, ``delta_pll``). The base raises so a PLL without a sequential init falls under the joint init.""" raise NotImplementedError( f"{type(self).__name__} provides no sequential PLL init; implement " f"finit_sequential() or set _init_method='joint' on the device." )
[docs] class SRF_PLL(PLL): """Synchronous-reference-frame PLL: locks the PLL frame to the filter voltage by driving its q-axis component to zero through a PI loop. States: ``epsilon`` (PI integrator), ``delta_pll`` (PLL-frame angle relative to the network). Equivalent to the PLL equations previously hardcoded in ``GridFollowing``; the trajectory is byte-identical. """
[docs] def states(self) -> List[str]: return ["epsilon", "delta_pll"]
[docs] def units(self) -> List[str]: return ["p.u.", "p.u."]
[docs] def params(self) -> Dict[str, float]: return {"Kpll_p": 0.5, "Kpll_i": 4.69}
[docs] def states_noise(self) -> Dict[str, float]: return {"epsilon": 1e-2, "delta_pll": 1e-2}
[docs] def states_init_error(self) -> Dict[str, float]: return {"epsilon": 1e-2, "delta_pll": 1e-2}
[docs] def x0(self) -> Dict[str, float]: return {"epsilon": 0, "delta_pll": 0}
[docs] def descriptions(self) -> Dict[str, str]: return { "Kpll_p": "Proportional gain for PLL", "Kpll_i": "Integral gain for PLL", "epsilon": "PLL integrator state", "delta_pll": "angle difference between the dq-reference frame of the PLL and the network", }
[docs] def fgcall(self, host, dae: Dae, omega_ref_vec, omega_b) -> None: Vfd_ext_s = host.var_sym(dae, "Vfd_ext") Vfq_ext_s = host.var_sym(dae, "Vfq_ext") delta_pll_s = host.var_sym(dae, "delta_pll") # PLL q-axis voltage: the q-component of the filter voltage rotated into # the PLL frame (the d-component is unused). Vectorized over devices; the # per-i expression is preserved verbatim -> byte-identical. _, Vfq_pll = host.to_internal(Vfd_ext_s, Vfq_ext_s, delta_pll_s) host.omega_pll = ( dae.omega_net + host.Kpll_p * Vfq_pll + host.Kpll_i * dae.x[host.epsilon] ) delta_omega_pll = host.omega_pll - omega_ref_vec dae.f[host.epsilon] = Vfq_pll dae.f[host.delta_pll] = omega_b * delta_omega_pll
[docs] def finit_sequential(self, host, dae: Dae, Vfd_ext, Vfq_ext) -> Dict[str, np.ndarray]: """PLL lock at steady state: the PLL frame aligns so its q-axis voltage is zero (``Vfq_pll = 0``), the integrator settles (``epsilon = 0``), and ``delta_pll`` is the filter-voltage angle. Solved as a 3x3 Newton for (Vfq_pll, epsilon, delta_pll); the first is an intermediate.""" n = host.n n_unknowns = 3 Vfq_pll = ca.SX.sym("Vfq_pll", n) epsilon = ca.SX.sym("epsilon", n) delta_pll = ca.SX.sym("delta_pll", n) outputs = ca.SX(np.zeros(n_unknowns * n)) outputs[:n] = ( Vfd_ext * np.sin(-delta_pll) + Vfq_ext * np.cos(-delta_pll) - Vfq_pll ) outputs[n : 2 * n] = Vfq_pll outputs[2 * n : 3 * n] = host.Kpll_p * Vfq_pll + host.Kpll_i * epsilon h = ca.Function( "h", [ca.vertcat(Vfq_pll, epsilon, delta_pll)], [ca.vertcat(outputs)] ) G = ca.rootfinder("G", "newton", h) sol = np.array(G(ca.vertcat(np.zeros(n_unknowns * n)))).flatten() return {"epsilon": sol[n : 2 * n], "delta_pll": sol[2 * n : 3 * n]}
PLL_REGISTRY: Dict[str, type] = { "SRF_PLL": SRF_PLL, }