# 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 outer voltage-control strategies (the reactive / voltage side).
The voltage controller is the AVR-analogue of the converter: it turns the
reactive-power / voltage setpoints into the voltage-magnitude reference ``Vcd``
that the inner control ladder regulates the capacitor voltage to. It owns the
reactive-power measurement state ``Qc_tilde`` and the ``Qref`` / ``Vref``
setpoints. See ``docs/inverter_modernization_design.md`` ยง4.
NOTE (Phase 2 boundary, same as the angle source): the strategy owns the
``Qc_tilde`` *state* but the host writes its measurement-filter equation
``d Qc_tilde/dt = omega_f (Qc - Qc_tilde)`` because ``Qc`` comes from the shared
Park-transform loop in ``Inverter.fgcall``.
"""
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 VoltageControl(ABC):
"""Abstract base class for inverter outer voltage-control strategies.
Must expose the voltage-magnitude reference: :meth:`fgcall` returns the ``Vcd``
vector consumed by the inner control ladder (host-mediated via
``host.voltage_command``). Reads host params/states/setpoints by attribute.
"""
[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):
"""Publish the voltage-magnitude reference on ``host.Vcd`` (read by the inner
controller via ``host.voltage_command(dae)``). The host writes the
``Qc_tilde`` measurement-filter equation (``Qc`` is host-computed)."""
...
[docs]
def finit_sequential(
self, host, dae: Dae, Qc: np.ndarray, Vcd: np.ndarray
) -> Dict[str, np.ndarray]:
"""Resolve the voltage controller's states/setpoints from the
(frame-invariant) reactive power ``Qc`` and the inner controller's voltage
command ``Vcd``. This is where the Q-V *gauge* lives. The base raises so a
new voltage law declares its own resolution."""
raise NotImplementedError(
f"{type(self).__name__} provides no sequential voltage init; implement "
f"finit_sequential() or set _init_method='joint' on the device."
)
[docs]
class QVDroop(VoltageControl):
"""Reactive-power / voltage droop: ``Vcd = Vref + Kq (Qref - Qc_tilde)``.
Owns the filtered reactive-power state ``Qc_tilde``, the droop gain ``Kq`` and
the ``Qref`` / ``Vref`` setpoints. Byte-identical to the previously inlined
voltage reference.
"""
[docs]
def states(self) -> List[str]:
return ["Qc_tilde"]
[docs]
def units(self) -> List[str]:
return ["p.u."]
[docs]
def params(self) -> Dict[str, float]:
return {"Kq": 0.1}
[docs]
def states_noise(self) -> Dict[str, float]:
return {"Qc_tilde": 1}
[docs]
def states_init_error(self) -> Dict[str, float]:
return {"Qc_tilde": 1e-1}
[docs]
def x0(self) -> Dict[str, float]:
return {"Qc_tilde": 0}
[docs]
def setpoints(self) -> Dict[str, float]:
return {"Qref": 0.01, "Vref": 1.05}
[docs]
def descriptions(self) -> Dict[str, str]:
return {
"Kq": "Droop coefficient for Q-V",
"Qref": "Reactive power set point",
"Vref": "Voltage set point",
"Qc_tilde": "Filtered internal reactive power",
}
[docs]
def fgcall(self, host, dae: Dae):
# Publish the voltage-magnitude command on the host (read by the inner
# controller via host.voltage_command). Vectorized; byte-identical.
host.Vcd = host.Vref + host.Kq * (host.Qref - dae.x[host.Qc_tilde])
[docs]
def finit_sequential(
self, host, dae: Dae, Qc: np.ndarray, Vcd: np.ndarray
) -> Dict[str, np.ndarray]:
# The Q-V droop Vcd = Vref + Kq(Qref - Qc_tilde) is static, so Qref/Vref are
# a 1-parameter gauge; the convention Qref = Qc (the dispatched reactive
# power, = Qc_tilde at steady state) zeroes the droop term, giving Vref =
# Vcd. The power filter settles at Qc_tilde = Qc.
return {"Qc_tilde": Qc, "Qref": Qc, "Vref": Vcd}
VOLTAGE_REGISTRY: Dict[str, type] = {
"QVDroop": QVDroop,
}