Repository navigation
ContingencyAnalysisCPP: connected N-1 rows fail after 1 iteration with a remote voltage regulator (depends on nb_thread) #240
Description
Environment
- Grid2op version: not used
- lightsim2grid version:
1.2.0,masteratfe623bf, built from source - pypowsybl version:
1.16.1 - System: Linux. Seen in the gpusim2grid CI (ubuntu 22.04, Python 3.10) and reproduced in a separate container (Python 3.13).
Bug description
When a generator regulates a remote bus, ContingencyAnalysisCPP reports several N-1 rows as not converged after a single iteration. Their voltages come back as all 0 and get_row_nb_iter() is 1. Yet for each of these rows:
- the grid stays connected (
is_grid_connected_after_contingency()is 1); - a single
LSGrid.ac_pfof the same contingency, started from the base-case voltages, converges in 3 to 4 iterations; - without the remote regulation (plain IEEE 14), every connected row converges.
Which rows fail depends on nb_thread. The result is deterministic for a given value: e.g. branch 5 fails with 1 or 8 threads but converges with 4. It does not depend on Vinit, init_from_n_powerflow, reuse_base_case or keep_vinit_at_group_controlled_buses. A single contingency on its own (add_n1(5) alone) fails too.
Because the outcome changes with the way rows are split across threads, some per-thread solver state seems to carry over from one row to the next, or to be set up differently from ac_pf. The VoltageControl state of the remote regulator is the obvious suspect.
How to reproduce
Code snippet
import numpy as np
import pypowsybl as pp
from lightsim2grid.network import init_from_pypowsybl
from lightsim2grid.contingencyAnalysis import ContingencyAnalysisCPP
# IEEE 14, with B6-G regulating the 12 kV bus of a load further away (a remote regulator)
net = pp.network.create_ieee14()
loads = net.get_loads(attributes=["voltage_level_id"])
load = loads.index[loads["voltage_level_id"] == "VL12"][0]
net.update_generators(id="B6-G", regulated_element_id=load, target_v=14.2, min_q=-9999., max_q=9999.)
grid = init_from_pypowsybl(net, gen_slack_id="B1-G", sort_index=True)
V0 = grid.ac_pf(np.ones(grid.total_bus(), dtype=complex), 30, 1e-11)
assert V0.shape[0] > 0
n_lines = len(grid.get_lines())
for nb_thread in (1, 4, 8):
ca = ContingencyAnalysisCPP(grid)
ca.nb_thread = nb_thread
ca.add_all_n1()
ca.compute(V0.copy(), 30, 1e-11)
connected = np.asarray(ca.is_grid_connected_after_contingency()).ravel().astype(bool)
failed = np.flatnonzero(connected & ~np.asarray(ca.converged_mask()))
print(f"nb_thread={nb_thread}: connected rows not converged {failed.tolist()}, "
f"iterations {np.asarray(ca.get_row_nb_iter())[failed].tolist()}")
# the same contingencies, one LSGrid.ac_pf each, from the base-case voltages
for b in (2, 3, 4, 5, 16, 17):
g = grid.copy()
g.deactivate_powerline(b) if b < n_lines else g.deactivate_trafo(b - n_lines)
V = g.ac_pf(V0.copy(), 30, 1e-11)
print(f"branch {b}: single ac_pf converged = {V.shape[0] > 0}")
# a single contingency alone fails too
ca = ContingencyAnalysisCPP(grid)
ca.add_n1(5)
ca.compute(V0.copy(), 30, 1e-11)
print("add_n1(5) alone: converged", ca.converged_mask(), "iterations", ca.get_row_nb_iter())Current output
nb_thread=1: connected rows not converged [2, 3, 4, 5, 16, 17], iterations [1, 1, 1, 1, 1, 1]
nb_thread=4: connected rows not converged [2, 3, 4, 16, 17], iterations [1, 1, 1, 1, 1]
nb_thread=8: connected rows not converged [2, 5, 17], iterations [1, 1, 1]
branch 2: single ac_pf converged = True
branch 3: single ac_pf converged = True
branch 4: single ac_pf converged = True
branch 5: single ac_pf converged = True
branch 16: single ac_pf converged = True
branch 17: single ac_pf converged = True
add_n1(5) alone: converged [False] iterations [1]
(Row 13, L7-8-1, islands bus 8 and is excluded above as expected. The failing branches are L10-11-1, L12-13-1, L13-14-1, L2-3-1, L9-14-1 and T4-7-1.)
Expected output
Every connected row converges, whatever nb_thread, as the single ac_pf of each contingency does.
For instance, for branch 5 (L2-3-1) the single solve gives B6-G's own bus at 14.430316 kV, which is what gpusim2grid's batched GPU contingency analysis also finds for that row. This came up in Grid2op/gpusim2grid#21, whose parity test against ContingencyAnalysisCPP now compares each row to the single ac_pf solve, and to ContingencyAnalysisCPP only where it converged.
Activity
Scope the stale-pivot retry to the batch sweeps, not ContinuationSweep
Merge origin/dev_1.3.0 into dev_outerloops