# Created: 2026-06-02
# (c) Copyright 2024 ETH Zurich, Milos Katanic
# https://doi.org/10.5905/ethz-1007-842
#
# 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.
"""Pluggable-governor test (governor-side counterpart of test_avr_algebraic).
The governor is now a pluggable strategy, symmetric to the AVR: its mechanical-
power coupling 'pm' may be a differential state (TGOV1, turbine lag dynamics) or
a device-private algebraic variable (Droop, instantaneous primary frequency
response). This validates:
1. The Droop governor's algebraic 'pm' satisfies its defining equation
pm = Pref - omega/Rd along the trajectory (internal consistency).
2. Droop (algebraic 'pm') reproduces TGOV1 with small turbine time constants in
the Tch,Tsv -> 0 singular-perturbation limit (Droop is that quasi-steady-state
limit), proving the state-vs-algebraic 'pm' coupling is interchangeable.
(Byte-equivalence of the TGOV1 extraction itself is covered by the unchanged
positional baselines in test_recur_sim/test_recur_est/test_run_ideal.)
See docs/algebraic_equations_design.md and CHANGELOG_algebraic_equations.md.
"""
import numpy as np
from pydynamicestimator.config import config
from pydynamicestimator.run import run
from pydynamicestimator.tests.test_avr_algebraic import _COMMON, _machine
[docs]
def test_droop_constraint_holds():
"""The Droop governor's algebraic mechanical power must satisfy its defining
equation along the whole trajectory: the recovered pm (in y_full) equals
Pref - (omega - omega_net)/Rd evaluated on the state. Note `omega` is the
ABSOLUTE per-unit speed, so the droop acts on the deviation (omega_net=1)."""
_, sim = run(config.updated(testsystemfile="gov_droop", **_COMMON))
m = _machine()
assert sim.n_priv == 1 # pm only
x = np.array(sim.x_full)
y = np.array(sim.y_full)
pm = y[m.pm[0]] # algebraic, recovered from the DAE solve
omega = x[m.omega[0]]
Pref, Rd = m.Pref[0], m.Rd[0]
expr = Pref - (omega - 1.0) / Rd # omega_net = 1 p.u.
assert np.abs(pm - expr).max() < 1e-6, (
f"pm violates its droop defining equation by "
f"{np.abs(pm - expr).max():.3e} (expected < 1e-6)."
)
[docs]
def test_droop_matches_tgov1_fast_limit():
"""Droop (pm algebraic) must reproduce TGOV1 with small turbine time constants
(pm a state behind the valve+chest lags) in the Tch,Tsv -> 0 limit. The
gov_tgov1_fast fixture uses Tch = Tsv = 1e-3, so the machine response must
agree to O(Tch,Tsv)."""
_, sim_g = run(config.updated(testsystemfile="gov_tgov1_fast", **_COMMON))
mg = _machine()
xg = np.array(sim_g.x_full)
pm_g = xg[mg.pm[0]] # pm is a STATE in TGOV1
delta_g = xg[mg.delta[0]]
omega_g = xg[mg.omega[0]]
_, sim_d = run(config.updated(testsystemfile="gov_droop", **_COMMON))
md = _machine()
xd = np.array(sim_d.x_full)
yd = np.array(sim_d.y_full)
pm_d = yd[md.pm[0]] # pm is ALGEBRAIC in Droop
delta_d = xd[md.delta[0]]
omega_d = xd[md.omega[0]]
# Structural difference: Droop has no turbine states (psv, pm) but adds pm as
# a private algebraic -> two fewer states, one more private.
assert sim_g.n_priv == 0 and sim_d.n_priv == 1
assert sim_d.nx == sim_g.nx - 2
assert sim_d.ny == sim_g.ny + 1
n = min(xg.shape[1], xd.shape[1])
assert np.isfinite(pm_d).all()
# Machine dynamics (slow states) are essentially identical...
assert np.abs(delta_g[:n] - delta_d[:n]).max() < 1e-3
assert np.abs(omega_g[:n] - omega_d[:n]).max() < 1e-4
# ...and the mechanical power agrees to the turbine-time-constant order.
assert np.abs(pm_g[:n] - pm_d[:n]).max() < 5e-3, (
f"Algebraic pm differs from the Tch,Tsv->0 TGOV1 pm by "
f"{np.abs(pm_g[:n] - pm_d[:n]).max():.3e} (expected O(Tch,Tsv), < 5e-3)."
)