pydynamicestimator.tests.test_dist_load ======================================= .. py:module:: pydynamicestimator.tests.test_dist_load .. autoapi-nested-parse:: 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 ---------- .. autoapisummary:: pydynamicestimator.tests.test_dist_load.FIXTURE_ROOT pydynamicestimator.tests.test_dist_load.TEMPLATE pydynamicestimator.tests.test_dist_load.BUS2_IDX pydynamicestimator.tests.test_dist_load._LIGHT_BASELINE Functions --------- .. autoapisummary:: pydynamicestimator.tests.test_dist_load._stage pydynamicestimator.tests.test_dist_load._base_cfg pydynamicestimator.tests.test_dist_load._bus_voltage_magnitude pydynamicestimator.tests.test_dist_load._pre_post_voltages pydynamicestimator.tests.test_dist_load.test_pure_z_load_step_converges pydynamicestimator.tests.test_dist_load.test_pure_p_load_large_step_dae_raises pydynamicestimator.tests.test_dist_load.test_pure_p_small_step_dae_converges pydynamicestimator.tests.test_dist_load.test_pure_i_small_step_dae_converges pydynamicestimator.tests.test_dist_load.test_pure_i_large_step_line_dyn_true_converges pydynamicestimator.tests.test_dist_load.test_mixed_z_dominant_zip_load_step_converges pydynamicestimator.tests.test_dist_load.test_legacy_static_load_impedance_treated_as_z_share_1 pydynamicestimator.tests.test_dist_load.test_legacy_static_load_power_treated_as_p_share_1 pydynamicestimator.tests.test_dist_load.test_no_load_at_bus_raises pydynamicestimator.tests.test_dist_load.test_stacking_two_load_events pydynamicestimator.tests.test_dist_load.test_negative_p_delta_raises_voltage pydynamicestimator.tests.test_dist_load.test_q_delta_sign_convention_inductive pydynamicestimator.tests.test_dist_load.test_line_dyn_true_z_share pydynamicestimator.tests.test_dist_load.test_q_share_resolver_falls_back_to_p_side pydynamicestimator.tests.test_dist_load.test_q_share_resolver_uses_explicit_q_side pydynamicestimator.tests.test_dist_load.test_q_share_resolver_mixed_per_entry pydynamicestimator.tests.test_dist_load.test_q_share_falls_back_in_dist_load pydynamicestimator.tests.test_dist_load.test_q_only_step_routes_through_q_share pydynamicestimator.tests.test_dist_load.test_heating_load_z_for_p_p_for_q pydynamicestimator.tests.test_dist_load.test_q_side_sum_to_1_warning pydynamicestimator.tests.test_dist_load.test_p_side_sum_to_1_warning_unchanged_behaviour pydynamicestimator.tests.test_dist_load.test_mixed_grammar_old_and_new_in_one_file pydynamicestimator.tests.test_dist_load.test_legacy_class_unaffected_by_q_share_extension Module Contents --------------- .. py:data:: FIXTURE_ROOT .. py:data:: TEMPLATE .. py:data:: BUS2_IDX :value: 1 .. py:function:: _stage(tmp_path: pathlib.Path, *, load_line: str | None = None, bus_init_line: str | None = None, sim_dist: str | None = None) -> pathlib.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. .. py:data:: _LIGHT_BASELINE :value: 'BusInit, bus = "2",\tp = 20,\tq = 5,\ttype = "PQ"' .. py:function:: _base_cfg(tmp_path: pathlib.Path, **overrides) Return a config that runs the staged scenario on the sim side. .. py:function:: _bus_voltage_magnitude(sim, bus_idx: int, k: int) -> float .. py:function:: _pre_post_voltages(sim, bus_idx: int = BUS2_IDX) -> tuple[float, float] Return (|V| at t ≈ 0.5s, |V| at t = T_end). .. py:function:: 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). .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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`). .. py:function:: 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. .. py:function:: 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. .. py:function:: test_stacking_two_load_events(tmp_path) Two LOAD events at the same bus should stack (post-second total demand = P₀ + 2·Δp). .. py:function:: test_negative_p_delta_raises_voltage(tmp_path) A negative p_delta = load reduction should RAISE the bus voltage. .. py:function:: test_q_delta_sign_convention_inductive(tmp_path) Positive q_delta = inductive consumption, should drop voltage magnitude (consistent with gcall_z's b convention). .. py:function:: 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. .. py:function:: 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). .. py:function:: test_q_share_resolver_uses_explicit_q_side() q_share(branch, k) returns the Q-side value when it's set (not NaN). .. py:function:: test_q_share_resolver_mixed_per_entry() Different entries can independently opt in or out of Q-side shares. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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. .. py:function:: 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.