"""Regression tests: simulation must stay finite and bounded after a fault
for every reference-frame mode (nom, coi, single, dist) and both line_dyn
settings.
Covers the bug where DaeSim.exec_dist() rebuilt self.x via init_symbolic()
but did not refresh self.omega_ref_*_expr, leaving the post-fault integrator
substituting placeholders with expressions that referenced the pre-rebuild
SX symbols. The symptom was post-fault divergence or garbage trajectories
for every omega_mode except 'nom' (whose ω_ref is a numeric constant).
"""
from pathlib import Path
import numpy as np
import pytest
from pydynamicestimator.config import config
from pydynamicestimator.run import run
FIXTURE_ROOT = Path(__file__).parent / "fixtures"
[docs]
def _base_config(omega_mode: str, line_dyn: bool, testsystemfile: str):
return config.updated(
testsystemfile=testsystemfile,
system_root=FIXTURE_ROOT,
omega_mode=omega_mode,
omega_single_idx="GFMI2",
line_dyn=line_dyn,
incl_lim=True,
skip_est=True,
plot=False,
plot_voltage=False,
plot_diff=False,
int_scheme_sim="cvodes" if line_dyn else "idas",
ts=0.0005 if line_dyn else 0.005,
T_start=0.0,
T_end=2.0,
log_level="WARNING",
)
[docs]
def _omega_state_indices(sim) -> list[int]:
"""Return indices of states whose name ends in '_omega' (synchronous-machine
rotor speeds and similar). Used as a sanity-check probe."""
return [i for i, s in enumerate(sim.states) if s.endswith("_omega")]
[docs]
def _assert_post_fault_healthy(sim, fault_time: float) -> None:
# Whole trajectory must be finite.
assert np.all(np.isfinite(sim.x_full)), "non-finite state encountered"
# Probe rotor-speed-like states post fault — these should stay near 1 p.u.
# for a small disturbance over the test horizon. This catches the
# "integrator silently produced garbage" failure mode.
fault_step = int(round(fault_time / sim.t))
omega_idx = _omega_state_indices(sim)
if omega_idx:
post = sim.x_full[np.ix_(omega_idx, range(fault_step, sim.x_full.shape[1]))]
assert np.all(post > 0.90) and np.all(
post < 1.10
), f"omega state left plausible band: min={post.min()}, max={post.max()}"
# If dist mode, delta_ref states must stay bounded (no runaway).
if sim.has_delta_ref:
delta_post = sim.x_full[
np.ix_(list(sim.idx_delta_ref), range(fault_step, sim.x_full.shape[1]))
]
assert np.all(np.abs(delta_post) < 1e3), "delta_ref state runaway"
[docs]
@pytest.mark.parametrize("omega_mode", ["nom", "coi", "single", "dist"])
@pytest.mark.parametrize("line_dyn", [True, False])
def test_open_line_post_fault(omega_mode: str, line_dyn: bool) -> None:
"""3_bus_lineopen: OPEN_LINE at t=1.0s. Sim must complete and stay sane."""
cfg = _base_config(omega_mode, line_dyn, testsystemfile="3_bus_lineopen")
_, sim = run(cfg)
assert sim.x_full.shape[1] == sim.nts
_assert_post_fault_healthy(sim, fault_time=1.0)
[docs]
@pytest.mark.parametrize("omega_mode", ["nom", "coi", "single", "dist"])
@pytest.mark.parametrize("line_dyn", [True, False])
def test_bus_fault_post_clear(omega_mode: str, line_dyn: bool) -> None:
"""3_bus_busfault: FAULT_BUS at t=1.0s, CLEAR at t=1.04s. Sim must
complete and recover to plausible state."""
cfg = _base_config(omega_mode, line_dyn, testsystemfile="3_bus_busfault")
_, sim = run(cfg)
assert sim.x_full.shape[1] == sim.nts
# Use the clear time as the start of the recovery window — that's when
# the network returns to a near-nominal topology.
_assert_post_fault_healthy(sim, fault_time=1.04)