pydynamicestimator.tests.test_dist_load

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.

Attributes

FIXTURE_ROOT

TEMPLATE

BUS2_IDX

_LIGHT_BASELINE

Functions

_stage(→ pathlib.Path)

Stage a scenario folder under tmp_path.

_base_cfg(tmp_path, **overrides)

Return a config that runs the staged scenario on the sim side.

_bus_voltage_magnitude(→ float)

_pre_post_voltages(→ tuple[float, float])

Return (|V| at t ≈ 0.5s, |V| at t = T_end).

test_pure_z_load_step_converges(tmp_path)

Pure-Z load + 30 MW step. Linear KCL — no PV nose, IDA converges.

test_pure_p_load_large_step_dae_raises(tmp_path)

Pure-P with the heavy default 100 MW baseline + 30 MW step under

test_pure_p_small_step_dae_converges(tmp_path)

Pure-P with a small (1 MW) baseline and step under DAE

test_pure_i_small_step_dae_converges(tmp_path)

Pure-I with a 2 MW step under DAE (line_dyn=False / idas).

test_pure_i_large_step_line_dyn_true_converges(tmp_path)

Pure-I with a 30 MW step under dynamic lines (line_dyn=True /

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

test_legacy_static_load_impedance_treated_as_z_share_1(...)

StaticLoadImpedance should be treated as z=1 (and produce results

test_legacy_static_load_power_treated_as_p_share_1(...)

StaticLoadPower must be inferred as p=1 and produce numerics

test_no_load_at_bus_raises(tmp_path)

LOAD disturbance at a bus without any load device (here: bus 1,

test_stacking_two_load_events(tmp_path)

Two LOAD events at the same bus should stack (post-second total

test_negative_p_delta_raises_voltage(tmp_path)

A negative p_delta = load reduction should RAISE the bus voltage.

test_q_delta_sign_convention_inductive(tmp_path)

Positive q_delta = inductive consumption, should drop voltage

test_line_dyn_true_z_share(tmp_path)

The line_dyn=True branch of dist_load must also be ZIP-aware

test_q_share_resolver_falls_back_to_p_side()

q_share(branch, k) returns the P-side share when Q-side is NaN

test_q_share_resolver_uses_explicit_q_side()

q_share(branch, k) returns the Q-side value when it's set (not NaN).

test_q_share_resolver_mixed_per_entry()

Different entries can independently opt in or out of Q-side shares.

test_q_share_falls_back_in_dist_load(tmp_path)

A bus declared with only P-side shares (no _share_q fields) must

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

test_heating_load_z_for_p_p_for_q(tmp_path)

The 'heating with controls' scenario from the design doc:

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.

test_p_side_sum_to_1_warning_unchanged_behaviour(...)

The sum-to-1 warning fires for the P-side too, mirroring the Q

test_mixed_grammar_old_and_new_in_one_file(tmp_path)

Verify the parser handles a sim_param.txt mixing old-style and

test_legacy_class_unaffected_by_q_share_extension(tmp_path)

StaticLoadImpedance has no q_share() method — the dist_load

Module Contents

pydynamicestimator.tests.test_dist_load.FIXTURE_ROOT
pydynamicestimator.tests.test_dist_load.TEMPLATE
pydynamicestimator.tests.test_dist_load.BUS2_IDX = 1
pydynamicestimator.tests.test_dist_load._stage(tmp_path: pathlib.Path, *, load_line: str | None = None, bus_init_line: str | None = None, sim_dist: str | None = None) pathlib.Path[source]

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.

Parameters:
  • tmp_path (pathlib.Path)

  • load_line (str | None)

  • bus_init_line (str | None)

  • sim_dist (str | None)

Return type:

pathlib.Path

pydynamicestimator.tests.test_dist_load._LIGHT_BASELINE = 'BusInit, bus = "2",\tp = 20,\tq = 5,\ttype = "PQ"'
pydynamicestimator.tests.test_dist_load._base_cfg(tmp_path: pathlib.Path, **overrides)[source]

Return a config that runs the staged scenario on the sim side.

Parameters:

tmp_path (pathlib.Path)

pydynamicestimator.tests.test_dist_load._bus_voltage_magnitude(sim, bus_idx: int, k: int) float[source]
Parameters:
  • bus_idx (int)

  • k (int)

Return type:

float

pydynamicestimator.tests.test_dist_load._pre_post_voltages(sim, bus_idx: int = BUS2_IDX) tuple[float, float][source]

Return (|V| at t ≈ 0.5s, |V| at t = T_end).

Parameters:

bus_idx (int)

Return type:

tuple[float, float]

pydynamicestimator.tests.test_dist_load.test_pure_z_load_step_converges(tmp_path)[source]

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).

pydynamicestimator.tests.test_dist_load.test_pure_p_load_large_step_dae_raises(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_pure_p_small_step_dae_converges(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_pure_i_small_step_dae_converges(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_pure_i_large_step_line_dyn_true_converges(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_mixed_z_dominant_zip_load_step_converges(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_legacy_static_load_impedance_treated_as_z_share_1(tmp_path)[source]

StaticLoadImpedance should be treated as z=1 (and produce results indistinguishable from StaticZIP, z_share=1).

pydynamicestimator.tests.test_dist_load.test_legacy_static_load_power_treated_as_p_share_1(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_no_load_at_bus_raises(tmp_path)[source]

LOAD disturbance at a bus without any load device (here: bus 1, the slack generator) must raise a clear ValueError.

pydynamicestimator.tests.test_dist_load.test_stacking_two_load_events(tmp_path)[source]

Two LOAD events at the same bus should stack (post-second total demand = P₀ + 2·Δp).

pydynamicestimator.tests.test_dist_load.test_negative_p_delta_raises_voltage(tmp_path)[source]

A negative p_delta = load reduction should RAISE the bus voltage.

pydynamicestimator.tests.test_dist_load.test_q_delta_sign_convention_inductive(tmp_path)[source]

Positive q_delta = inductive consumption, should drop voltage magnitude (consistent with gcall_z’s b convention).

pydynamicestimator.tests.test_dist_load.test_line_dyn_true_z_share(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_q_share_resolver_falls_back_to_p_side()[source]

q_share(branch, k) returns the P-side share when Q-side is NaN (the default / ‘not declared’ case).

pydynamicestimator.tests.test_dist_load.test_q_share_resolver_uses_explicit_q_side()[source]

q_share(branch, k) returns the Q-side value when it’s set (not NaN).

pydynamicestimator.tests.test_dist_load.test_q_share_resolver_mixed_per_entry()[source]

Different entries can independently opt in or out of Q-side shares.

pydynamicestimator.tests.test_dist_load.test_q_share_falls_back_in_dist_load(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_q_only_step_routes_through_q_share(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_heating_load_z_for_p_p_for_q(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_q_side_sum_to_1_warning(tmp_path, caplog)[source]

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.

pydynamicestimator.tests.test_dist_load.test_p_side_sum_to_1_warning_unchanged_behaviour(tmp_path, caplog)[source]

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.

pydynamicestimator.tests.test_dist_load.test_mixed_grammar_old_and_new_in_one_file(tmp_path)[source]

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.

pydynamicestimator.tests.test_dist_load.test_legacy_class_unaffected_by_q_share_extension(tmp_path)[source]

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.