"""ZIP-aware LOAD disturbance handler — comprehensive tests.
Exercises `Dae.dist_load` across every load-model variant, the two
line_dyn branches, sign conventions, and error handling.
Strategy: each test programmatically builds a one-scenario testcase
under `tmp_path` by copying the frozen `3_bus_loadstep/` fixture and
rewriting the `StaticZIP` line (or substituting `StaticLoadImpedance`
/ `StaticLoadPower`) plus the disturbance line. This avoids a
fixture-folder explosion while keeping the test inputs reproducible —
the test body is the source of truth.
"""
from __future__ import annotations
from pathlib import Path
import re
import shutil
import numpy as np
import pytest
from pydynamicestimator.config import config
from pydynamicestimator.run import run
FIXTURE_ROOT = Path(__file__).parent / "fixtures"
TEMPLATE = FIXTURE_ROOT / "3_bus_loadstep"
BUS2_IDX = 1 # 0-based bus index in the 3-bus grid (bus 2 is the loaded one)
# -------------------------------------------------------------------- helpers
[docs]
def _stage(
tmp_path: Path,
*,
load_line: str | None = None,
bus_init_line: str | None = None,
sim_dist: str | None = None,
) -> Path:
"""Stage a scenario folder under tmp_path.
Copies the 3_bus_loadstep template; optionally rewrites the load
line for bus 2, the BusInit line for bus 2, and the entire
sim_dist.txt content.
The default 3-bus fixture has a 100 MW + 10 MVAr load at bus 2.
For pure-P or pure-I share variants, this baseline is past the
static voltage-stability limit (a known property of constant-power
loads near the grid's PV nose); use *bus_init_line* to dial the
baseline back into the stable region for those tests.
"""
scen = tmp_path / "scenario"
scen.mkdir(parents=True, exist_ok=False)
for name in ("sim_param.txt", "sim_dist.txt", "est_param.txt", "est_dist.txt"):
src = TEMPLATE / name
if src.exists():
(scen / name).write_text(src.read_text())
sp = scen / "sim_param.txt"
text = sp.read_text()
if load_line is not None:
text = re.sub(
r"^StaticZIP,\s*bus\s*=\s*\"2\".*$",
load_line,
text,
count=1,
flags=re.MULTILINE,
)
if bus_init_line is not None:
text = re.sub(
r"^BusInit,\s*bus\s*=\s*\"2\".*$",
bus_init_line,
text,
count=1,
flags=re.MULTILINE,
)
sp.write_text(text)
if sim_dist is not None:
(scen / "sim_dist.txt").write_text(sim_dist.rstrip() + "\n")
return tmp_path
# Stable baseline load magnitudes for the non-Z variants. The default
# 100 MW + 10 MVAr at bus 2 lives near the static voltage-stability
# limit when expressed as pure-P / pure-I; scale back so each variant
# has a comfortable operating point.
_LIGHT_BASELINE = 'BusInit, bus = "2",\tp = 20,\tq = 5,\ttype = "PQ"'
[docs]
def _base_cfg(tmp_path: Path, **overrides):
"""Return a config that runs the staged scenario on the sim side."""
kwargs = dict(
testsystemfile="scenario",
system_root=tmp_path,
omega_mode="nom",
line_dyn=False,
incl_lim=True,
skip_est=True,
plot=False,
plot_voltage=False,
plot_diff=False,
int_scheme_sim="idas",
ts=0.005,
T_start=0.0,
T_end=2.0,
log_level="WARNING",
print_power_flow=False,
)
kwargs.update(overrides)
return config.updated(**kwargs)
[docs]
def _bus_voltage_magnitude(sim, bus_idx: int, k: int) -> float:
return float(np.hypot(sim.y_full[2 * bus_idx, k], sim.y_full[2 * bus_idx + 1, k]))
[docs]
def _pre_post_voltages(sim, bus_idx: int = BUS2_IDX) -> tuple[float, float]:
"""Return (|V| at t ≈ 0.5s, |V| at t = T_end)."""
pre_idx = int(0.5 / sim.t) # well before t=1.0 disturbance
post_idx = -1
return (
_bus_voltage_magnitude(sim, bus_idx, pre_idx),
_bus_voltage_magnitude(sim, bus_idx, post_idx),
)
# -------------------------------------------------------------------- tests
# ---------- per-share-type, line_dyn=False --------------------------------
[docs]
def test_pure_z_load_step_converges(tmp_path):
"""Pure-Z load + 30 MW step. Linear KCL — no PV nose, IDA converges.
The 3-bus grid is tightly coupled, so the steady-state voltage drop
is modest (~0.01 pu); the key invariants are (a) the sim runs to
completion without IDA_CONV_FAIL and (b) voltage sags (not rises).
"""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-3, (
f"Voltage should sag after Z load step: pre={v_pre:.6f}, post={v_post:.6f}"
)
assert v_post > 0.5 # not collapsed
[docs]
def test_pure_p_load_large_step_dae_raises(tmp_path):
"""Pure-P with the heavy default 100 MW baseline + 30 MW step under
the DAE formulation (line_dyn=False / idas). IDA must fail. This
confirms (a) the user's "P" choice is honoured by the handler
(not silently swallowed) and (b) the well-known DAE-side numerical
fragility of constant-P loads on a small grid is surfaced as an
error rather than masked.
"""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.0, i_share = 0.0, p_share = 1.0',
)
with pytest.raises(RuntimeError, match="IDA"):
run(_base_cfg(tmp_path))
[docs]
def test_pure_p_small_step_dae_converges(tmp_path):
"""Pure-P with a small (1 MW) baseline and step under DAE
(line_dyn=False / idas) — exercises the P-share branch of
dist_load where IDA's algebraic-IC computation can still bridge
the disturbance cleanly.
"""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.0, i_share = 0.0, p_share = 1.0',
bus_init_line='BusInit, bus = "2",\tp = 1,\tq = 0,\ttype = "PQ"',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 1, q_delta = 0',
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-5
[docs]
def test_pure_i_small_step_dae_converges(tmp_path):
"""Pure-I with a 2 MW step under DAE (line_dyn=False / idas).
Larger pure-I steps trigger IDA Newton failure at the disturbance
boundary (the atan2/cos/sin nonlinearity in gcall_i makes
consistent-IC computation across a step harder than for a Z
increment), so we test the small-step regime here.
"""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.0, i_share = 1.0, p_share = 0.0',
bus_init_line=_LIGHT_BASELINE,
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 2, q_delta = 0',
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-5
[docs]
def test_pure_i_large_step_line_dyn_true_converges(tmp_path):
"""Pure-I with a 30 MW step under dynamic lines (line_dyn=True /
cvodes). The ODE formulation has no algebraic-IC computation, so
even large pure-I steps integrate cleanly. Establishes that the
handler's I-share math is correct — the DAE-side failures
elsewhere in this file are an IDA convergence property, not a
dist_load math error.
"""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.0, i_share = 1.0, p_share = 0.0',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 30, q_delta = 0',
)
cfg = _base_cfg(
tmp_path,
line_dyn=True,
int_scheme_sim="cvodes",
ts=0.0001,
T_end=1.2,
incl_lim=False,
)
est, sim = run(cfg)
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-4
[docs]
def test_mixed_z_dominant_zip_load_step_converges(tmp_path):
"""Mixed Z/I/P shares with Z-dominant (0.8/0.1/0.1). The Z anchor
keeps the baseline stable while still exercising the I and P
branches of dist_load. Verifies that the share-apportioned
increment correctly sags the bus voltage."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.8, i_share = 0.1, p_share = 0.1',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 20, q_delta = 0',
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-3
# ---------- legacy load classes ------------------------------------------
[docs]
def test_legacy_static_load_impedance_treated_as_z_share_1(tmp_path):
"""`StaticLoadImpedance` should be treated as z=1 (and produce results
indistinguishable from `StaticZIP, z_share=1`)."""
# ZIP-z=1 reference
_stage(
tmp_path / "zip",
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
)
_, sim_zip = run(_base_cfg(tmp_path / "zip"))
# Legacy StaticLoadImpedance scenario
_stage(
tmp_path / "leg",
load_line='StaticLoadImpedance, bus = "2"',
)
_, sim_leg = run(_base_cfg(tmp_path / "leg"))
# Voltages should agree to numerical noise
v_zip = np.hypot(sim_zip.y_full[2 * BUS2_IDX], sim_zip.y_full[2 * BUS2_IDX + 1])
v_leg = np.hypot(sim_leg.y_full[2 * BUS2_IDX], sim_leg.y_full[2 * BUS2_IDX + 1])
assert np.allclose(v_zip, v_leg, atol=1e-8)
[docs]
def test_legacy_static_load_power_treated_as_p_share_1(tmp_path):
"""`StaticLoadPower` must be inferred as p=1 and produce numerics
indistinguishable from an explicit `StaticZIP(p_share=1)` of the
same rated power. Run both on a small-step regime where the
P-share branch converges cleanly, then compare voltage
trajectories.
"""
common_dist = 'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 1, q_delta = 0'
light = 'BusInit, bus = "2",\tp = 1,\tq = 0,\ttype = "PQ"'
_stage(
tmp_path / "zip",
load_line='StaticZIP, bus = "2", z_share = 0.0, i_share = 0.0, p_share = 1.0',
bus_init_line=light,
sim_dist=common_dist,
)
_, sim_zip = run(_base_cfg(tmp_path / "zip"))
_stage(
tmp_path / "leg",
load_line='StaticLoadPower, bus = "2"',
bus_init_line=light,
sim_dist=common_dist,
)
_, sim_leg = run(_base_cfg(tmp_path / "leg"))
v_zip = np.hypot(sim_zip.y_full[2 * BUS2_IDX], sim_zip.y_full[2 * BUS2_IDX + 1])
v_leg = np.hypot(sim_leg.y_full[2 * BUS2_IDX], sim_leg.y_full[2 * BUS2_IDX + 1])
assert np.allclose(v_zip, v_leg, atol=1e-9)
# ---------- error handling ------------------------------------------------
[docs]
def test_no_load_at_bus_raises(tmp_path):
"""LOAD disturbance at a bus without any load device (here: bus 1,
the slack generator) must raise a clear ValueError."""
_stage(
tmp_path,
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "1", p_delta = 5, q_delta = 0',
)
with pytest.raises(ValueError, match="no load device"):
run(_base_cfg(tmp_path))
# ---------- semantics: stacking, sign conventions, negative steps --------
[docs]
def test_stacking_two_load_events(tmp_path):
"""Two LOAD events at the same bus should stack (post-second total
demand = P₀ + 2·Δp)."""
# Two events: +10 MW at t=1.0, +10 MW at t=1.5
two_events = (
'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 10, q_delta = 0\n'
'Disturbance, time = 1.5, type = "LOAD", bus = "2", p_delta = 10, q_delta = 0\n'
)
_stage(
tmp_path / "two",
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
sim_dist=two_events,
)
_, sim_two = run(_base_cfg(tmp_path / "two"))
# One event of 20 MW for comparison
_stage(
tmp_path / "one",
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 20, q_delta = 0',
)
_, sim_one = run(_base_cfg(tmp_path / "one"))
# After t=1.5 both runs should converge to the same steady state
# (within transient damping). Test the final voltage.
v_two_final = _bus_voltage_magnitude(sim_two, BUS2_IDX, -1)
v_one_final = _bus_voltage_magnitude(sim_one, BUS2_IDX, -1)
assert abs(v_two_final - v_one_final) < 0.02, (
f"Two-step stacked outcome diverges from single 20MW step: "
f"two={v_two_final}, one={v_one_final}"
)
[docs]
def test_negative_p_delta_raises_voltage(tmp_path):
"""A negative p_delta = load reduction should RAISE the bus voltage."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = -20, q_delta = 0',
)
est, sim = run(_base_cfg(tmp_path))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post > v_pre + 1e-4, (
f"Voltage should rise after load reduction: pre={v_pre:.6f}, post={v_post:.6f}"
)
[docs]
def test_q_delta_sign_convention_inductive(tmp_path):
"""Positive q_delta = inductive consumption, should drop voltage
magnitude (consistent with gcall_z's b convention)."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 0, q_delta = 20',
)
est, sim = run(_base_cfg(tmp_path))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-4, (
f"Positive q_delta should drop voltage: pre={v_pre:.6f}, post={v_post:.6f}"
)
# ---------- line_dyn=True path -------------------------------------------
[docs]
def test_line_dyn_true_z_share(tmp_path):
"""The line_dyn=True branch of dist_load must also be ZIP-aware
(it modifies dae.fnode via the ω_b / B_sum capacitor coupling
instead of dae.g). Use Z-only + modest step + short horizon."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 10, q_delta = 0',
)
cfg = _base_cfg(
tmp_path,
line_dyn=True,
int_scheme_sim="cvodes",
ts=0.0001,
T_end=1.05,
)
est, sim = run(cfg)
assert np.all(np.isfinite(sim.x_full))
# =========================================================================
# Independent Q-side shares
# =========================================================================
#
# These tests cover the optional `p_share_q` / `i_share_q` / `z_share_q`
# fields on StaticZIP (the IEEE-polynomial-ZIP-style extension). See
# `docs/load_disturbance_design.html` §7 for the design.
#
# Two layers of testing:
# (a) Unit tests of the q_share() resolver on a freshly-instantiated
# StaticZIP — pure logic, no simulation.
# (b) End-to-end tests that stage a sim_param.txt with explicit Q-side
# shares and verify the full pipeline (finit_sub decomposition →
# dist_load Q-axis weighting → KCL response).
[docs]
def test_q_share_resolver_falls_back_to_p_side():
"""q_share(branch, k) returns the P-side share when Q-side is NaN
(the default / 'not declared' case)."""
from pydynamicestimator.devices.static import StaticZIP
dev = StaticZIP()
dev.z_share = np.array([0.7, 0.3])
dev.i_share = np.array([0.2, 0.4])
dev.p_share = np.array([0.1, 0.3])
dev.z_share_q = np.array([np.nan, np.nan])
dev.i_share_q = np.array([np.nan, np.nan])
dev.p_share_q = np.array([np.nan, np.nan])
for k in (0, 1):
assert dev.q_share("z", k) == dev.z_share[k]
assert dev.q_share("i", k) == dev.i_share[k]
assert dev.q_share("p", k) == dev.p_share[k]
[docs]
def test_q_share_resolver_uses_explicit_q_side():
"""q_share(branch, k) returns the Q-side value when it's set (not NaN)."""
from pydynamicestimator.devices.static import StaticZIP
dev = StaticZIP()
dev.z_share = np.array([0.5])
dev.i_share = np.array([0.0])
dev.p_share = np.array([0.5])
dev.z_share_q = np.array([1.0])
dev.i_share_q = np.array([0.0])
dev.p_share_q = np.array([0.0])
assert dev.q_share("z", 0) == 1.0
assert dev.q_share("i", 0) == 0.0
assert dev.q_share("p", 0) == 0.0
[docs]
def test_q_share_resolver_mixed_per_entry():
"""Different entries can independently opt in or out of Q-side shares."""
from pydynamicestimator.devices.static import StaticZIP
dev = StaticZIP()
dev.z_share = np.array([0.6, 1.0])
dev.i_share = np.array([0.0, 0.0])
dev.p_share = np.array([0.4, 0.0])
dev.z_share_q = np.array([np.nan, 0.0])
dev.i_share_q = np.array([np.nan, 0.0])
dev.p_share_q = np.array([np.nan, 1.0])
assert dev.q_share("z", 0) == 0.6 # fallback
assert dev.q_share("p", 0) == 0.4 # fallback
assert dev.q_share("z", 1) == 0.0 # explicit
assert dev.q_share("p", 1) == 1.0 # explicit
[docs]
def test_q_share_falls_back_in_dist_load(tmp_path):
"""A bus declared with only P-side shares (no _share_q fields) must
produce numerically identical dist_load behaviour to an explicit
redeclaration where the Q-side equals the P-side. This is the
backward-compatibility property — the fallback path can't drift
from the historical single-share calibration."""
common_dist = 'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 5, q_delta = 1'
_stage(
tmp_path / "implicit",
load_line='StaticZIP, bus = "2", z_share = 1.0',
sim_dist=common_dist,
)
_, sim_a = run(_base_cfg(tmp_path / "implicit"))
_stage(
tmp_path / "explicit",
load_line='StaticZIP, bus = "2", z_share = 1.0, i_share = 0.0, p_share = 0.0, '
'z_share_q = 1.0, i_share_q = 0.0, p_share_q = 0.0',
sim_dist=common_dist,
)
_, sim_b = run(_base_cfg(tmp_path / "explicit"))
assert np.allclose(sim_a.x_full, sim_b.x_full, atol=1e-10)
assert np.allclose(sim_a.y_full, sim_b.y_full, atol=1e-10)
[docs]
def test_q_only_step_routes_through_q_share(tmp_path):
"""A pure Q-only LOAD step (p_delta=0, q_delta>0) on a bus with
z_share_q = 1.0 (vs the implicit z_share_q = 0.5) should produce a
different voltage response. Confirms the Q-side share is actually
wired into dist_load's Q-axis contributions.
Uses a modest step (q_delta=3) at the default 100 MW + 10 MVAr baseline:
a larger step here sits at the static voltage-stability nose, where the
integration is on a knife-edge (and previously only succeeded by luck of
the solver's floating-point path). The routing signal at this step is small
(~3e-6) but ~10 orders above the pre-disturbance match (~1e-15), so it is an
unambiguous, robust check that Q routing changes the response."""
common_dist = 'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 0, q_delta = 3'
_stage(
tmp_path / "old",
load_line='StaticZIP, bus = "2", z_share = 0.5, p_share = 0.5',
sim_dist=common_dist,
)
_, sim_old = run(_base_cfg(tmp_path / "old"))
_stage(
tmp_path / "qz1",
load_line='StaticZIP, bus = "2", z_share = 0.5, p_share = 0.5, '
'z_share_q = 1.0, p_share_q = 0.0',
sim_dist=common_dist,
)
_, sim_qz = run(_base_cfg(tmp_path / "qz1"))
v_old = np.hypot(sim_old.y_full[2 * BUS2_IDX], sim_old.y_full[2 * BUS2_IDX + 1])
v_qz = np.hypot(sim_qz.y_full[2 * BUS2_IDX], sim_qz.y_full[2 * BUS2_IDX + 1])
# Pre-disturbance values must match (init is independent of dist_load).
assert np.isclose(v_old[100], v_qz[100], atol=1e-6)
# Post-disturbance steady state must differ because Q routing differs.
# Signal is ~3e-6 at this step/operating point; threshold sits well above
# the ~1e-15 pre-disturbance match yet below the routing signal.
assert abs(v_old[-1] - v_qz[-1]) > 1e-6, (
f"Voltages should differ when Q is routed differently: "
f"old={v_old[-1]:.6f}, qz={v_qz[-1]:.6f}"
)
[docs]
def test_heating_load_z_for_p_p_for_q(tmp_path):
"""The 'heating with controls' scenario from the design doc:
z_share=1 / p_share=0 (active power purely resistive) plus
z_share_q=0 / p_share_q=1 (reactive power purely constant-power).
End-to-end sanity: init succeeds, small LOAD step simulates
cleanly, voltage moves the right direction."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, p_share = 0.0, '
'z_share_q = 0.0, p_share_q = 1.0',
bus_init_line='BusInit, bus = "2",\tp = 5,\tq = 1,\ttype = "PQ"',
sim_dist='Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 2, q_delta = 0.5',
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
v_pre, v_post = _pre_post_voltages(sim)
assert v_post < v_pre - 1e-5
[docs]
def test_q_side_sum_to_1_warning(tmp_path, caplog):
"""If Q-side shares don't sum to 1.0, StaticZIP.finit logs a warning.
We only need init to fire — no disturbance involved — so empty out
sim_dist and use a light pure-Z baseline that's robust to the
deliberate Q-side misconfiguration."""
import logging
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 1.0, p_share = 0.0, '
'z_share_q = 0.3, p_share_q = 0.4', # Q sum = 0.7 (and i_share_q falls back to i_share=0)
bus_init_line='BusInit, bus = "2",\tp = 5,\tq = 1,\ttype = "PQ"',
sim_dist="# no disturbance — only init exercises the warning",
)
with caplog.at_level(logging.WARNING):
run(_base_cfg(tmp_path))
qside_warnings = [
rec for rec in caplog.records
if "Q-side shares" in rec.message and "≠ 1.0" in rec.message
]
assert qside_warnings, (
f"Expected a Q-side sum-to-1 warning; got log records:\n"
+ "\n".join(rec.message for rec in caplog.records[:20])
)
[docs]
def test_p_side_sum_to_1_warning_unchanged_behaviour(tmp_path, caplog):
"""The sum-to-1 warning fires for the P-side too, mirroring the Q
check. Sanity that the new code path didn't drop the P check."""
import logging
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.3, p_share = 0.4', # P sum = 0.7
bus_init_line='BusInit, bus = "2",\tp = 5,\tq = 1,\ttype = "PQ"',
sim_dist="# no disturbance — only init exercises the warning",
)
with caplog.at_level(logging.WARNING):
run(_base_cfg(tmp_path))
pside_warnings = [
rec for rec in caplog.records
if "P-side shares" in rec.message and "≠ 1.0" in rec.message
]
assert pside_warnings
[docs]
def test_mixed_grammar_old_and_new_in_one_file(tmp_path):
"""Verify the parser handles a sim_param.txt mixing old-style and
new-style StaticZIP lines side by side — entries with no
`*_share_q` fields fall back via NaN, entries with them use them.
Use Z-dominant shares + a light baseline so the P-share portion
doesn't tip bus 2 into the constant-P-instability region."""
_stage(
tmp_path,
load_line='StaticZIP, bus = "2", z_share = 0.9, p_share = 0.1, '
'z_share_q = 1.0, p_share_q = 0.0',
bus_init_line='BusInit, bus = "2",\tp = 10,\tq = 1,\ttype = "PQ"',
sim_dist="# no disturbance — exercising only parser + finit_sub",
)
est, sim = run(_base_cfg(tmp_path))
assert np.all(np.isfinite(sim.x_full))
[docs]
def test_legacy_class_unaffected_by_q_share_extension(tmp_path):
"""`StaticLoadImpedance` has no q_share() method — the dist_load
handler must fall back to identical P/Q shares (z=1 across both
sides) and produce results equivalent to a `StaticZIP(z=1)` with
no _share_q fields. Confirms the hasattr() guard works."""
common_dist = 'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 5, q_delta = 1'
_stage(
tmp_path / "legacy",
load_line='StaticLoadImpedance, bus = "2"',
sim_dist=common_dist,
)
_, sim_l = run(_base_cfg(tmp_path / "legacy"))
_stage(
tmp_path / "zip",
load_line='StaticZIP, bus = "2", z_share = 1.0',
sim_dist=common_dist,
)
_, sim_z = run(_base_cfg(tmp_path / "zip"))
v_l = np.hypot(sim_l.y_full[2 * BUS2_IDX], sim_l.y_full[2 * BUS2_IDX + 1])
v_z = np.hypot(sim_z.y_full[2 * BUS2_IDX], sim_z.y_full[2 * BUS2_IDX + 1])
assert np.allclose(v_l, v_z, atol=1e-8)