# Created: 2026-06-03
# (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-PSS test (the inter-strategy coupling case).
The power system stabilizer is a pluggable strategy whose output 'Vs' is summed
into the AVR voltage error -- a strategy-to-strategy coupling kept host-mediated
(`Synchronous.pss_signal` returns the 'Vs' symbol, or 0 when no PSS is present;
each AVR reads it). 'Vs' is a device-private algebraic (washout + lead-lag has
direct feedthrough on the speed deviation). This validates:
1. The PSSKundur output satisfies its defining equation along the trajectory.
2. The washout blocks DC: 'Vs' is ~0 at steady state and only acts on the
post-disturbance transient.
3. The PSS measurably changes the machine dynamics versus no PSS (the
host-mediated PSS -> AVR -> machine coupling works), and adds exactly its
three states + one private algebraic.
(Byte-equivalence of the no-PSS path 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
_T_DIST = 3.0 # disturbance time in the pss_on / pss_off fixtures (sim_dist.txt)
[docs]
def test_pss_constraint_holds():
"""The PSS's algebraic output must satisfy its defining equation along the
whole trajectory: Vs == vl2*(1-T3/T4) + (T3/T4)*y1, with
y1 = vl1*(1-T1/T2) + (T1/T2)*w and washout output w = K_stab*(omega-1) - vw.
Note `omega` is the ABSOLUTE per-unit speed, so the PSS input is the deviation
omega - omega_net (omega_net = 1 p.u.)."""
_, sim = run(config.updated(testsystemfile="pss_on", **_COMMON))
m = _machine()
assert sim.n_priv == 1 # Vs only
x = np.array(sim.x_full)
y = np.array(sim.y_full)
Vs = y[m.Vs[0]]
omega = x[m.omega[0]]
vw, vl1, vl2 = x[m.vw[0]], x[m.vl1[0]], x[m.vl2[0]]
K, T1, T2, T3, T4 = m.K_stab[0], m.T1[0], m.T2[0], m.T3[0], m.T4[0]
w = K * (omega - 1.0) - vw # omega_net = 1 p.u.
y1 = vl1 * (1 - T1 / T2) + (T1 / T2) * w
expr = vl2 * (1 - T3 / T4) + (T3 / T4) * y1
assert np.abs(Vs - expr).max() < 1e-6, (
f"Vs violates its PSS defining equation by {np.abs(Vs - expr).max():.3e} "
f"(expected < 1e-6)."
)
[docs]
def test_pss_washout_and_coupling():
"""The PSS adds its three states + one private algebraic; its washout blocks
DC (Vs ~ 0 at the pre-disturbance steady state, active afterwards); and it
measurably changes the machine dynamics versus the no-PSS reference."""
_, sim_off = run(config.updated(testsystemfile="pss_off", **_COMMON))
m_off = _machine()
omega_off = np.array(sim_off.x_full)[m_off.omega[0]]
_, sim_on = run(config.updated(testsystemfile="pss_on", **_COMMON))
m_on = _machine()
x = np.array(sim_on.x_full)
y = np.array(sim_on.y_full)
Vs = y[m_on.Vs[0]]
omega_on = x[m_on.omega[0]]
# Structural: PSS adds 3 differential states (vw, vl1, vl2) and 1 private (Vs).
assert sim_off.n_priv == 0 and sim_on.n_priv == 1
assert sim_on.nx == sim_off.nx + 3
assert sim_on.ny == sim_off.ny + 1
# Locate the disturbance column.
n = Vs.shape[0]
dt = (_COMMON["T_end"] - _COMMON["T_start"]) / (n - 1)
i_dist = int(round(_T_DIST / dt))
# Washout / DC block: Vs is ~0 in the (settled) pre-disturbance window...
pre = Vs[: i_dist]
assert np.abs(pre).max() < 1e-9, (
f"Vs is not ~0 at steady state (washout should block DC); "
f"max|Vs_pre| = {np.abs(pre).max():.3e}"
)
# ...and genuinely active after the disturbance.
post = Vs[i_dist:]
assert np.abs(post).max() > 1e-4, "PSS produced no signal after the disturbance"
# Coupling works: the PSS measurably changes the rotor-speed response.
mlen = min(omega_off.shape[0], omega_on.shape[0])
assert np.abs(omega_on[:mlen] - omega_off[:mlen]).max() > 1e-6, (
"PSS had no effect on the machine dynamics -- the PSS->AVR coupling is "
"not taking effect"
)
[docs]
def test_pss_per_machine_vectorization():
"""With multiple machines in one vectorized device group, each PSS must use
its OWN absolute speed minus the common nominal (omega_i - omega_net) and its
OWN parameters -- not machine 0's. Two SGs carry PSSKundur with different
K_stab/Tw/T*; each machine's Vs must satisfy its own defining equation."""
_, sim = run(config.updated(testsystemfile="pss_two_machine", **_COMMON))
m = _machine()
assert m.n == 2, "expected a 2-machine vectorized device group"
assert sim.n_priv == 2 # one Vs per machine
# Distinct per-machine parameters (rules out accidental aliasing of params).
assert m.K_stab[0] != m.K_stab[1]
x = np.array(sim.x_full)
y = np.array(sim.y_full)
# The two machines must actually have distinct speed trajectories, otherwise
# the "own omega" check below would be vacuous.
assert np.abs(x[m.omega[0]] - x[m.omega[1]]).max() > 1e-6
for i in range(m.n):
omega = x[m.omega[i]]
vw, vl1, vl2 = x[m.vw[i]], x[m.vl1[i]], x[m.vl2[i]]
Vs = y[m.Vs[i]]
K, Tw, T1, T2, T3, T4 = (
m.K_stab[i], m.Tw[i], m.T1[i], m.T2[i], m.T3[i], m.T4[i]
)
w = K * (omega - 1.0) - vw # omega_net = 1 p.u., each machine's own omega
y1 = vl1 * (1 - T1 / T2) + (T1 / T2) * w
expr = vl2 * (1 - T3 / T4) + (T3 / T4) * y1
assert np.abs(Vs - expr).max() < 1e-6, (
f"machine {i}: Vs does not satisfy its own PSS equation "
f"(uses its own omega/params); max err {np.abs(Vs - expr).max():.3e}"
)