From 9b58b81994681561d963dcfb3d8078439d5f2248 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 21 Sep 2026 20:15:31 +0200 Subject: [PATCH 1/7] Add lightsim2grid as a power flow solver (pf_solver: lightsim2grid) lightsim2grid replaces the PF solver in PF mode, next to powermodel and powsybl: settings: pf_solver: lightsim2grid The network is read with the native reader, then converted to a lightsim2grid LSGrid straight from the Network arrays (init_from_matpower: no pandapower, no pypowsybl). OPF is still solved by PowerModels. New package gridfm_datakit/lightsim2grid, laid out like gridfm_datakit/powsybl: - api: availability check (is_lightsim2grid_available, check_...) - mapping: index maps between the Network and the LSGrid (buses and generators keep the row order, branches are split in powerlines and transformers) - convert: to_lightsim2grid, update_lightsim2grid, initial_voltage - preprocess: run_ls_pf / get_pf_res, which format the AC or DC result like the PowerModels one so pf_post_processing consumes it unchanged update_lightsim2grid pushes only what changed since the last synchronisation (branch parameters and statuses, generator statuses and set points, loads) into the same LSGrid. It rebuilds it when something it cannot update changed (topology, tap ratio, phase shift, shunts, bus types, which buses have a load, the slack generators). The worker keeps the LSGrid in meta, so it is reused across the perturbations and the scenarios it processes. The lightsim2grid setters ignore changes of at most 1e-7, so such a change is applied through a detour to keep the LSGrid exactly equal to the Network (otherwise the data would depend on the previous scenarios processed by the worker). In-place parameter updates need the update_powerlines_parameters / update_trafos_parameters methods of lightsim2grid. The new optional dependency `lightsim2grid` (pyproject.toml) is pinned to >=1.1.0: it has to be moved to a release that contains them. Checked on case14 (the AC results match PowerModels' fast PF to 1e-6 on every column; the DC ones differ by design: lightsim2grid uses the MATPOWER DC model, 1/(x.tap), PowerModels x/(r^2+x^2)) and timed on case118 and case2000. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- gridfm_datakit/generate.py | 8 +- gridfm_datakit/lightsim2grid/__init__.py | 67 +++++++ gridfm_datakit/lightsim2grid/api.py | 26 +++ gridfm_datakit/lightsim2grid/convert.py | 205 +++++++++++++++++++++ gridfm_datakit/lightsim2grid/mapping.py | 49 +++++ gridfm_datakit/lightsim2grid/preprocess.py | 110 +++++++++++ gridfm_datakit/process/process_network.py | 56 +++++- pyproject.toml | 4 + tests/lightsim2grid/test_generate.py | 194 +++++++++++++++++++ 9 files changed, 713 insertions(+), 6 deletions(-) create mode 100644 gridfm_datakit/lightsim2grid/__init__.py create mode 100644 gridfm_datakit/lightsim2grid/api.py create mode 100644 gridfm_datakit/lightsim2grid/convert.py create mode 100644 gridfm_datakit/lightsim2grid/mapping.py create mode 100644 gridfm_datakit/lightsim2grid/preprocess.py create mode 100644 tests/lightsim2grid/test_generate.py diff --git a/gridfm_datakit/generate.py b/gridfm_datakit/generate.py index 14ef7ee41..97e8f55eb 100644 --- a/gridfm_datakit/generate.py +++ b/gridfm_datakit/generate.py @@ -12,6 +12,7 @@ import yaml from tqdm import tqdm +import gridfm_datakit.lightsim2grid as lightsim2grid import gridfm_datakit.powsybl as powsybl from gridfm_datakit.network import ( Network, @@ -129,10 +130,13 @@ def _setup_environment( # In OPF mode the value is read and stored on args but is never consulted # during execution — it is kept here purely for consistency and logging. pf_solver = getattr(args.settings, "pf_solver", "powermodel") - if pf_solver not in ("powermodel", "powsybl"): + if pf_solver not in ("powermodel", "powsybl", "lightsim2grid"): raise ValueError( - f"settings.pf_solver must be 'powermodel' or 'powsybl', got {pf_solver!r}", + "settings.pf_solver must be 'powermodel', 'powsybl' or 'lightsim2grid', " + f"got {pf_solver!r}", ) + if pf_solver == "lightsim2grid": + lightsim2grid.check_lightsim2grid_available() args.settings.pf_solver = pf_solver opf_formulation = getattr(args.settings, "opf_formulation", "polar") diff --git a/gridfm_datakit/lightsim2grid/__init__.py b/gridfm_datakit/lightsim2grid/__init__.py new file mode 100644 index 000000000..14d52cc24 --- /dev/null +++ b/gridfm_datakit/lightsim2grid/__init__.py @@ -0,0 +1,67 @@ +""" +lightsim2grid integration module for gridfm_datakit. + +lightsim2grid is a C++ power flow engine. This module bridges it with +gridfm_datakit's internal :class:`~gridfm_datakit.network.Network`, following +the layout of :mod:`gridfm_datakit.powsybl`: + +* :func:`convert_net` — convert a gridfm_datakit Network to a lightsim2grid + ``LSGrid`` and the index maps between the two. +* :func:`update_lightsim2grid` — re-synchronise the ``LSGrid`` with a perturbed + copy of the Network. +* :func:`run_ls_pf` — run an AC or DC power flow and format the result like + PowerModels' one, for ``pf_post_processing``. + +Unlike PowSyBl there is no dedicated reader: the network is always read with +the native reader and lightsim2grid only replaces the power flow solver +(``settings.pf_solver: lightsim2grid``). OPF is still solved by PowerModels. +""" + +from dataclasses import dataclass +from typing import Any + +from gridfm_datakit.network import Network + +from .api import ( + check_lightsim2grid_available, + is_lightsim2grid_available, + lightsim2grid_network, +) +from .convert import ConvertedNetwork, to_lightsim2grid, update_lightsim2grid +from .mapping import MappingL2G, build_l2g_maps +from .preprocess import get_pf_res, run_ls_pf + + +@dataclass +class LoadedNetwork: + """Bundles the lightsim2grid and gridfm_datakit representations of a network.""" + + ls_net: Any # lightsim2grid.network.LSGrid + gfm_net: Network + mapping_l2g: MappingL2G + + +def convert_net(network: Network) -> LoadedNetwork: + """Convert a gridfm_datakit Network to lightsim2grid.""" + conv = to_lightsim2grid(network) + return LoadedNetwork( + ls_net=conv.ls_net, + gfm_net=network, + mapping_l2g=conv.mapping_l2g, + ) + + +__all__ = [ + "convert_net", + "to_lightsim2grid", + "update_lightsim2grid", + "build_l2g_maps", + "MappingL2G", + "ConvertedNetwork", + "LoadedNetwork", + "lightsim2grid_network", + "is_lightsim2grid_available", + "check_lightsim2grid_available", + "run_ls_pf", + "get_pf_res", +] diff --git a/gridfm_datakit/lightsim2grid/api.py b/gridfm_datakit/lightsim2grid/api.py new file mode 100644 index 000000000..376f72c67 --- /dev/null +++ b/gridfm_datakit/lightsim2grid/api.py @@ -0,0 +1,26 @@ +try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from lightsim2grid import network as lightsim2grid_network + + LIGHTSIM2GRID_AVAILABLE = hasattr(lightsim2grid_network, "init_from_matpower") +except ImportError: + LIGHTSIM2GRID_AVAILABLE = False + lightsim2grid_network = None + + +def is_lightsim2grid_available() -> bool: + """Check if a lightsim2grid version able to read MATPOWER data is available.""" + return LIGHTSIM2GRID_AVAILABLE + + +def check_lightsim2grid_available() -> None: + """Check if lightsim2grid is available, raise ImportError if not.""" + if not LIGHTSIM2GRID_AVAILABLE: + raise ImportError( + "A recent lightsim2grid (with lightsim2grid.network.init_from_matpower) " + "is required for this functionality. " + "Install it with: pip install gridfm-datakit[lightsim2grid]", + ) diff --git a/gridfm_datakit/lightsim2grid/convert.py b/gridfm_datakit/lightsim2grid/convert.py new file mode 100644 index 000000000..b842afc80 --- /dev/null +++ b/gridfm_datakit/lightsim2grid/convert.py @@ -0,0 +1,205 @@ +"""Conversion of a gridfm_datakit Network to a lightsim2grid LSGrid.""" + +import warnings +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +import numpy as np + +from gridfm_datakit.network import Network +from gridfm_datakit.utils.idx_brch import ( + BR_B, + BR_R, + BR_STATUS, + BR_X, + F_BUS, + SHIFT, + T_BUS, + TAP, +) +from gridfm_datakit.utils.idx_bus import BS, BUS_I, BUS_TYPE, GS, PD, QD, REF, VA, VM +from gridfm_datakit.utils.idx_gen import GEN_BUS, GEN_STATUS, PG, VG + +from .api import check_lightsim2grid_available, lightsim2grid_network +from .mapping import MappingL2G, build_l2g_maps + + +@dataclass +class ConvertedNetwork: + """A lightsim2grid LSGrid together with its maps to the gridfm Network.""" + + ls_net: Any # lightsim2grid.network.LSGrid + mapping_l2g: MappingL2G + # what was last pushed to ls_net (see _snapshot), to push only the differences next time + state: Dict[str, Any] = field(default_factory=dict, repr=False) + + +def to_lightsim2grid(net: Network) -> ConvertedNetwork: + """Build a lightsim2grid LSGrid from the *current* state of ``net``. + + Loads, generator set points, statuses and branch parameters are all read + from ``net.buses`` / ``net.gens`` / ``net.branches``, so the result is + valid for one perturbed copy of the network only. Use + :func:`update_lightsim2grid` to re-synchronise it with another one. + """ + check_lightsim2grid_available() + mpc = { + "bus": net.buses, + "gen": net.gens, + "branch": net.branches, + "baseMVA": float(net.baseMVA), + } + with warnings.catch_warnings(): + # e.g. BASE_KV == 0 everywhere: voltages are then reported in pu, which is what we want + warnings.simplefilter("ignore") + ls_net = lightsim2grid_network.init_from_matpower(mpc) + + mapping = build_l2g_maps(net) + assert len(ls_net.get_lines()) == len(mapping.line_rows) + assert len(ls_net.get_trafos()) == len(mapping.trafo_rows) + assert len(ls_net.get_generators()) == net.gens.shape[0] + assert ls_net.total_bus() == net.buses.shape[0] + return ConvertedNetwork( + ls_net=ls_net, + mapping_l2g=mapping, + state=_snapshot(net, mapping), + ) + + +def _structure(net: Network) -> tuple: + """What can only be changed by rebuilding the LSGrid.""" + bus_type = np.zeros(net.buses.shape[0]) + bus_type[net.buses[:, BUS_I].astype(int)] = net.buses[:, BUS_TYPE] + slack = np.flatnonzero( + (net.gens[:, GEN_STATUS] > 0) & (bus_type[net.gens[:, GEN_BUS].astype(int)] == REF), + ) + return ( + net.buses[:, [BUS_I, BUS_TYPE, GS, BS]].copy(), + net.branches[:, [F_BUS, T_BUS, TAP, SHIFT]].copy(), + net.gens[:, GEN_BUS].copy(), + ((net.buses[:, PD] != 0) | (net.buses[:, QD] != 0)), # which buses have a load + slack, + ) + + +def _snapshot(net: Network, mapping: MappingL2G) -> Dict[str, Any]: + """Copy of everything of ``net`` that the LSGrid was built from. + + ``structure`` holds what can only be changed by rebuilding the LSGrid; the + other entries are what :func:`update_lightsim2grid` can push in place. + """ + structure = _structure(net) + has_load = structure[3] + return { + "structure": structure, + "load_rows": np.flatnonzero(has_load), + "branch": net.branches[:, [BR_R, BR_X, BR_B, BR_STATUS]].copy(), + "gen": net.gens[:, [GEN_STATUS, PG, VG]].copy(), + "load": net.buses[has_load][:, [PD, QD]].copy(), + } + + +def _same_structure(net: Network, state: Dict[str, Any]) -> bool: + """Whether the LSGrid of ``state`` can be updated in place to match ``net``.""" + return all( + np.array_equal(a, b) for a, b in zip(_structure(net), state["structure"]) + ) + + +def update_lightsim2grid( + net: Network, + converted: Optional[ConvertedNetwork] = None, +) -> ConvertedNetwork: + """Return a lightsim2grid model in sync with ``net``. + + With no ``converted`` the LSGrid is built from scratch. Otherwise the + LSGrid of ``converted`` is updated *in place* with what changed since it + was last synchronised (branch parameters and statuses, generator statuses + and set points, loads), which lets lightsim2grid keep its solver caches. + It is rebuilt only if something it cannot update changed (topology, taps + and shifts, shunts, bus types, which buses have a load, the slack + generators). ``converted`` is modified and returned. + """ + if converted is None or not _same_structure(net, converted.state): + return to_lightsim2grid(net) + + ls_net, mapping, old = converted.ls_net, converted.mapping_l2g, converted.state + try: + _update_in_place(ls_net, net, mapping, old) + except Exception: + # half-updated: nothing about the LSGrid can be trusted anymore + return to_lightsim2grid(net) + return converted + + +# lightsim2grid's change_* setters ignore a change of at most this (BaseConstants::_tol_equal_float) +_LS_TOL_EQUAL_FLOAT = 1e-7 + + +def _set_exact(setter: Any, el_id: int, old: float, new: float) -> None: + """Call ``setter(el_id, new)`` and make sure that ``new`` is what gets stored. + + A change smaller than ``_LS_TOL_EQUAL_FLOAT`` is silently dropped by the + lightsim2grid setters, which would leave the LSGrid up to that far from + ``net``, and make the data depend on which perturbations came before. Such a + change is applied through a detour, in two steps that are both large enough. + """ + if abs(new - old) <= _LS_TOL_EQUAL_FLOAT: + setter(el_id, float(new) + 10 * _LS_TOL_EQUAL_FLOAT) + setter(el_id, float(new)) + + +def _update_in_place( + ls_net: Any, + net: Network, + mapping: MappingL2G, + old: Dict[str, Any], +) -> None: + branch = net.branches[:, [BR_R, BR_X, BR_B, BR_STATUS]] + + # series impedance and charging admittance (the LSGrid has no per-element setter) + for rows, update, split_b in ( + (mapping.line_rows, ls_net.update_powerlines_parameters, True), + (mapping.trafo_rows, ls_net.update_trafos_parameters, False), + ): + if rows.size and not np.array_equal(branch[rows, :3], old["branch"][rows, :3]): + r, x, b = branch[rows, 0], branch[rows, 1], 1j * branch[rows, 2] + if split_b: # a line: the charging is split in two halves + update(r, x, b / 2, b / 2) + else: # a transformer: the total charging is given + update(r, x, b) + + # branch statuses + for rows, deactivate, reactivate in ( + (mapping.line_rows, ls_net.deactivate_powerline, ls_net.reactivate_powerline), + (mapping.trafo_rows, ls_net.deactivate_trafo, ls_net.reactivate_trafo), + ): + for k in np.flatnonzero(branch[rows, 3] != old["branch"][rows, 3]): + (reactivate if branch[rows[k], 3] != 0 else deactivate)(int(k)) + + # generators + gen = net.gens[:, [GEN_STATUS, PG, VG]] + for i in np.flatnonzero(gen[:, 0] != old["gen"][:, 0]): + (ls_net.reactivate_gen if gen[i, 0] > 0 else ls_net.deactivate_gen)(int(i)) + for i in np.flatnonzero(gen[:, 1] != old["gen"][:, 1]): + _set_exact(ls_net.change_p_gen, int(i), old["gen"][i, 1], gen[i, 1]) + for i in np.flatnonzero(gen[:, 2] != old["gen"][:, 2]): + _set_exact(ls_net.change_v_gen, int(i), old["gen"][i, 2], gen[i, 2]) + + # loads (one per bus that has one) + load = net.buses[old["load_rows"]][:, [PD, QD]] + for k in np.flatnonzero(load[:, 0] != old["load"][:, 0]): + _set_exact(ls_net.change_p_load, int(k), old["load"][k, 0], load[k, 0]) + for k in np.flatnonzero(load[:, 1] != old["load"][:, 1]): + _set_exact(ls_net.change_q_load, int(k), old["load"][k, 1], load[k, 1]) + + old["branch"] = branch.copy() + old["gen"] = gen.copy() + old["load"] = load.copy() + + +def initial_voltage(net: Network) -> np.ndarray: + """Complex initial voltage (one entry per lightsim2grid bus) from ``net.buses``.""" + return (net.buses[:, VM] * np.exp(1j * np.deg2rad(net.buses[:, VA]))).astype( + complex, + ) diff --git a/gridfm_datakit/lightsim2grid/mapping.py b/gridfm_datakit/lightsim2grid/mapping.py new file mode 100644 index 000000000..448036047 --- /dev/null +++ b/gridfm_datakit/lightsim2grid/mapping.py @@ -0,0 +1,49 @@ +"""Index maps between a gridfm_datakit Network and a lightsim2grid LSGrid. + +``lightsim2grid.network.init_from_matpower`` documents a deterministic layout: + +* one lightsim2grid bus per row of ``net.buses`` (same order), +* one lightsim2grid generator per row of ``net.gens`` (same order, out of + service ones included), +* branches are split in *powerlines* and *transformers*, each list keeping the + order of the rows of ``net.branches``. A branch is a transformer if + ``TAP != 0`` or ``SHIFT != 0``. +""" + +from dataclasses import dataclass + +import numpy as np + +from gridfm_datakit.network import Network +from gridfm_datakit.utils.idx_brch import SHIFT, TAP +from gridfm_datakit.utils.idx_bus import BUS_I + + +@dataclass +class MappingL2G: + """lightsim2grid-to-gridfm index maps. + + Attributes + ---------- + line_rows : np.ndarray + ``line_rows[k]`` is the ``net.branches`` row of the k-th lightsim2grid powerline. + trafo_rows : np.ndarray + ``trafo_rows[k]`` is the ``net.branches`` row of the k-th lightsim2grid transformer. + bus_index : np.ndarray + ``bus_index[r]`` is the continuous bus index (``BUS_I``) of the r-th + lightsim2grid bus (= r-th row of ``net.buses``). + """ + + line_rows: np.ndarray + trafo_rows: np.ndarray + bus_index: np.ndarray + + +def build_l2g_maps(net: Network) -> MappingL2G: + """Build the index maps of the LSGrid that :func:`to_lightsim2grid` creates from ``net``.""" + is_trafo = (net.branches[:, TAP] != 0) | (net.branches[:, SHIFT] != 0) + return MappingL2G( + line_rows=np.flatnonzero(~is_trafo), + trafo_rows=np.flatnonzero(is_trafo), + bus_index=net.buses[:, BUS_I].astype(int), + ) diff --git a/gridfm_datakit/lightsim2grid/preprocess.py b/gridfm_datakit/lightsim2grid/preprocess.py new file mode 100644 index 000000000..877fca6da --- /dev/null +++ b/gridfm_datakit/lightsim2grid/preprocess.py @@ -0,0 +1,110 @@ +""" +lightsim2grid power flow results preprocessing module. + +Format lightsim2grid power flow results into PowerModels' power flow format +(per-unit, radians), the same layout the PowSyBl integration produces, so that +the existing ``pf_post_processing`` can consume them. +""" + +import time +from typing import Any, Dict + +import numpy as np + +from gridfm_datakit.network import Network +from gridfm_datakit.utils.idx_bus import BUS_I + +from .convert import initial_voltage +from .mapping import MappingL2G + + +def run_ls_pf( + ls_net: Any, + net: Network, + mapping_l2g: MappingL2G, + dc: bool = False, + max_iter: int = 50, + tol: float = 1e-8, +) -> Dict[str, Any]: + """Run an AC (or DC) power flow with lightsim2grid and format the results. + + Raises: + ValueError: if the power flow did not converge. + """ + v_init = initial_voltage(net) + start_time = time.perf_counter() + v = (ls_net.dc_pf if dc else ls_net.ac_pf)(v_init, max_iter, tol) + solve_time = time.perf_counter() - start_time + return get_pf_res(ls_net, v, solve_time, net, mapping_l2g) + + +def get_pf_res( + ls_net: Any, + v: np.ndarray, + solve_time: float, + net: Network, + mapping_l2g: MappingL2G, +) -> Dict[Any, Any]: + """Format lightsim2grid power flow results for the pf_post_process function. + + Args: + ls_net: lightsim2grid LSGrid, on which a power flow was just run + v: complex voltage returned by the power flow (empty if it diverged) + solve_time: power flow solving time + net: gridfm Network the LSGrid was built from + mapping_l2g: lightsim2grid-to-gridfm index maps + + Returns: + Power flow results in a nested Dict format, similar to PowerModel's power flow results + """ + if v.shape[0] == 0: + raise ValueError("Power flow computation failed: lightsim2grid did not converge") + + base_mva = float(net.baseMVA) + n_branches = net.branches.shape[0] + + # Branch flows in pu, (n_branches, 4) as pf, qf, pt, qt. Powerline + # and transformer results are scattered back to their branch rows. + flows = np.zeros((n_branches, 4)) + for rows, res1, res2 in ( + (mapping_l2g.line_rows, ls_net.get_line_res1(), ls_net.get_line_res2()), + (mapping_l2g.trafo_rows, ls_net.get_trafo_res1(), ls_net.get_trafo_res2()), + ): + flows[rows, 0] = res1[0] + flows[rows, 1] = res1[1] + flows[rows, 2] = res2[0] + flows[rows, 3] = res2[1] + flows /= base_mva + + gen_p, gen_q, _ = ls_net.get_gen_res() + gen_p = np.asarray(gen_p) / base_mva + gen_q = np.asarray(gen_q) / base_mva + + reverse = net.reverse_bus_index_mapping + vm, va = np.abs(v), np.angle(v) + + return { + "solution": { + "baseMVA": base_mva, + "gen": { + str(int(i) + 1): {"pg": gen_p[i], "qg": gen_q[i]} + for i in net.idx_gens_in_service + }, + "branch": { + str(i + 1): { + "pf": flows[i, 0], + "qf": flows[i, 1], + "pt": flows[i, 2], + "qt": flows[i, 3], + } + for i in range(n_branches) + }, + "bus": { + str(reverse[int(net.buses[r, BUS_I])]): {"vm": vm[r], "va": va[r]} + for r in range(net.buses.shape[0]) + }, + "per_unit": True, + "pf": True, + }, + "solve_time": solve_time, + } diff --git a/gridfm_datakit/process/process_network.py b/gridfm_datakit/process/process_network.py index ff5380f21..53a53bd76 100644 --- a/gridfm_datakit/process/process_network.py +++ b/gridfm_datakit/process/process_network.py @@ -14,6 +14,7 @@ import numpy as np +import gridfm_datakit.lightsim2grid as lightsim2grid import gridfm_datakit.powsybl as powsybl from gridfm_datakit.network import Network, branch_vectors, makeYbus from gridfm_datakit.perturbations.admittance_perturbation import AdmittanceGenerator @@ -1068,9 +1069,9 @@ def process_scenario_pf_mode( produces the generator set-points before topology perturbation. pf_solver: Which engine to use for the power flow solve after topology - perturbation. Must be ``'powermodel'`` (default) or - ``'powsybl'``. OPF is always solved by PowerModels regardless - of this value. + perturbation. Must be ``'powermodel'`` (default), + ``'powsybl'`` or ``'lightsim2grid'``. OPF is always solved by + PowerModels regardless of this value. Keyword-only arguments (only required when ``pf_solver='powsybl'``) ------------------------------------------------------------------- @@ -1133,6 +1134,9 @@ def process_scenario_pf_mode( base_variant_id = pp_net.get_working_variant_id() lf_params = powsybl.get_default_lf_params() + if pf_solver == "lightsim2grid": + lightsim2grid.check_lightsim2grid_available() + # to get PF points that can violate some OPF inequality constraints (to train PF solvers that can handle points outside of normal operating limits), we apply the topology perturbation after OPF. # The setpoints are then no longer adapted to the new topology, and might lead to e.g. abranch overload or a voltage magnitude violation once we drop an element. for pert_index, perturbation in enumerate(perturbations): @@ -1156,6 +1160,50 @@ def process_scenario_pf_mode( ) continue + if pf_solver == "lightsim2grid": + try: + # kept in meta so the same LSGrid is updated in place across the + # perturbations (and scenarios) handled by this worker + converted = lightsim2grid.update_lightsim2grid( + perturbation, + None if meta is None else meta.get("ls_converted"), + ) + if meta is not None: + meta["ls_converted"] = converted + except Exception as e: + with open(error_log_file, "a") as f: + f.write( + f"Caught an exception at scenario {scenario_index} when building the lightsim2grid model: {e}\n", + ) + continue + + res_dcpf = None + if include_dc_res: + try: + res_dcpf = lightsim2grid.run_ls_pf( + converted.ls_net, + perturbation, + converted.mapping_l2g, + dc=True, + ) + except Exception as e: + with open(error_log_file, "a") as f: + f.write( + f"Caught an exception at scenario {scenario_index} when solving dcpf function with lightsim2grid solver: {e}\n", + ) + try: + res = lightsim2grid.run_ls_pf( + converted.ls_net, + perturbation, + converted.mapping_l2g, + ) + except Exception as e: + with open(error_log_file, "a") as f: + f.write( + f"Caught an exception at scenario {scenario_index} when solving in run_pf function with lightsim2grid solver: {e}\n", + ) + continue + if pf_solver == "powsybl": variant_id = f"scenario_{scenario_index}_perturbation_{pert_index}" pp_net.clone_variant(base_variant_id, variant_id) @@ -1366,7 +1414,7 @@ def process_scenario_chunk( solver_log_dir: Directory for solver logs. max_iter: Maximum iterations for the solver. seed: Global random seed for reproducibility. - pf_solver: PF solver to use in pf mode; either 'powermodel' or 'powsybl'. + pf_solver: PF solver to use in pf mode; one of 'powermodel', 'powsybl' or 'lightsim2grid'. OPF is always solved by PowerModels regardless of this value. meta: metadata dict; when pf_solver='powsybl', must contain 'network_path' and 'mapping_p2g'. 'pp_net' is loaded fresh per worker from 'network_path'. diff --git a/pyproject.toml b/pyproject.toml index 8a7d67943..5922bab6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,10 @@ powsybl = [ "pypowsybl" ] +lightsim2grid = [ + "lightsim2grid>=1.1.0" +] + dynamic = [ "pypowsybl", "zarr", diff --git a/tests/lightsim2grid/test_generate.py b/tests/lightsim2grid/test_generate.py new file mode 100644 index 000000000..e27436358 --- /dev/null +++ b/tests/lightsim2grid/test_generate.py @@ -0,0 +1,194 @@ +"""Tests for the lightsim2grid power flow solver (``settings.pf_solver: lightsim2grid``).""" + +import copy +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from gridfm_datakit import generate_power_flow_data +from gridfm_datakit import lightsim2grid as l2g +from gridfm_datakit.network import load_net_from_file +from gridfm_datakit.process.process_network import _solution_arrays + +pytestmark = pytest.mark.skipif( + not l2g.is_lightsim2grid_available(), + reason="lightsim2grid is not installed. Install with: pip install gridfm-datakit[lightsim2grid]", +) + +_GRID = Path(__file__).parents[1] / "powsybl" / "grids" / "ieee14.m" + +_BASE_CONFIG = { + "network": { + "name": "ieee14", + "source": "file", + "network_dir": str(_GRID.parent), + }, + "load": { + "generator": "agg_load_profile", + "agg_profile": "default", + "scenarios": 5, + "sigma": 0.2, + "change_reactive_power": True, + "global_range": 0.4, + "max_scaling_factor": 4.0, + "step_size": 0.05, + "start_scaling_factor": 0.8, + }, + "topology_perturbation": {"type": "none"}, + "generation_perturbation": {"type": "cost_permutation"}, + "admittance_perturbation": {"type": "random_perturbation", "sigma": 0.2}, + "settings": { + "num_processes": 1, + "large_chunk_size": 5, + "overwrite": True, + "mode": "pf", + "include_dc_res": True, + "enable_solver_logs": False, + "pf_fast": True, + "dcpf_fast": True, + "max_iter": 200, + "pf_solver": "lightsim2grid", + "seed": 42, + }, +} + + +def test_mapping_and_ac_pf_are_consistent(): + """Every branch/gen/bus is mapped, and the AC PF closes the power balance.""" + net = load_net_from_file(str(_GRID)) + conv = l2g.convert_net(net) + mapping = conv.mapping_l2g + all_rows = np.sort(np.concatenate([mapping.line_rows, mapping.trafo_rows])) + assert np.array_equal(all_rows, np.arange(net.branches.shape[0])) + + res = l2g.run_ls_pf(conv.ls_net, net, mapping) + flows, gen_pq, bus_vmva = _solution_arrays(res, net) + assert flows.shape == (len(net.idx_branches_in_service), 4) + assert gen_pq.shape == (len(net.idx_gens_in_service), 2) + # losses are positive: pf + pt >= 0 on every branch + assert np.all(flows[:, 0] + flows[:, 2] > -1e-9) + assert np.all(np.isfinite(bus_vmva)) + + +def test_diverging_pf_raises(): + net = load_net_from_file(str(_GRID)) + net.buses[:, 2] *= 100 # absurd load: no solution + conv = l2g.convert_net(net) + with pytest.raises(ValueError, match="did not converge"): + l2g.run_ls_pf(conv.ls_net, net, conv.mapping_l2g) + + +def test_generate_pf_mode(tmp_path): + config = copy.deepcopy(_BASE_CONFIG) + config["settings"]["data_dir"] = str(tmp_path) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + generate_power_flow_data(config) + raw = tmp_path / "ieee14" / "raw" + assert (raw / "error.log").read_text().count("Caught an exception") == 0 + bus = pd.read_parquet(raw / "bus_data.parquet") + assert bus["scenario"].nunique() == 5 + assert np.all(np.isfinite(bus[["Vm", "Va"]].to_numpy())) + + +def _perturbed(net, rng, step): + """A perturbed copy of ``net``: loads, and depending on the step admittances, + a branch outage, generator set points and a generator outage (never the slack).""" + from gridfm_datakit.utils.idx_brch import BR_B, BR_R, BR_STATUS, BR_X + from gridfm_datakit.utils.idx_bus import PD, QD + from gridfm_datakit.utils.idx_gen import GEN_STATUS, PG, VG + + p = net.copy_for_perturbation() + f = 1 + 0.1 * rng.uniform(-1, 1, p.buses.shape[0]) + p.buses[:, PD] *= f + p.buses[:, QD] *= f + if step % 2: + for col in (BR_R, BR_X, BR_B): + p.branches[:, col] *= 1 + 0.2 * rng.uniform(-1, 1, p.branches.shape[0]) + if step % 3 == 0: + p.branches[rng.integers(p.branches.shape[0]), BR_STATUS] = 0 + if step % 5 == 0: + p.gens[:, PG] *= 1 + 0.1 * rng.uniform(-1, 1, p.gens.shape[0]) + p.gens[1:, VG] += 0.005 + if step % 10 == 0: + p.gens[3, GEN_STATUS] = 0 + return p + + +def test_in_place_update_matches_a_rebuild(): + """Updating the LSGrid in place, across perturbations, must give exactly what a fresh LSGrid gives.""" + net = load_net_from_file(str(_GRID)) + rng = np.random.default_rng(0) + converted = None + n_compared = n_rebuilt = 0 + for step in range(30): + p = _perturbed(net, rng, step) + previous = converted + converted = l2g.update_lightsim2grid(p, converted) + n_rebuilt += converted is not previous + fresh = l2g.to_lightsim2grid(p) + for dc in (False, True): + results = [] + for conv in (converted, fresh): + try: + results.append( + _solution_arrays( + l2g.run_ls_pf(conv.ls_net, p, conv.mapping_l2g, dc=dc), + p, + ), + ) + except ValueError: # diverged + results.append(None) + assert (results[0] is None) == (results[1] is None) + if results[0] is None: + continue + n_compared += 1 + for updated, rebuilt in zip(*results): + np.testing.assert_allclose(updated, rebuilt, atol=1e-10, equal_nan=True) + assert n_compared > 40 + assert n_rebuilt == 1 # only the initial build: everything else went in place + + +def test_structural_change_triggers_a_rebuild(): + """A change the LSGrid cannot take in place (here a tap ratio) rebuilds it.""" + from gridfm_datakit.utils.idx_brch import TAP + + net = load_net_from_file(str(_GRID)) + converted = l2g.update_lightsim2grid(net) + p = net.copy_for_perturbation() + trafo_row = converted.mapping_l2g.trafo_rows[0] + p.branches[trafo_row, TAP] *= 1.05 + updated = l2g.update_lightsim2grid(p, converted) + assert updated is not converted + ref = l2g.to_lightsim2grid(p) + a = _solution_arrays(l2g.run_ls_pf(updated.ls_net, p, updated.mapping_l2g), p) + b = _solution_arrays(l2g.run_ls_pf(ref.ls_net, p, ref.mapping_l2g), p) + for u, v in zip(a, b): + np.testing.assert_allclose(u, v, atol=1e-10) + + +def test_in_place_update_applies_changes_below_lightsim2grid_tolerance(): + """lightsim2grid ignores setter changes <= 1e-7: the update must still be exact, + otherwise the data would depend on what the worker solved before.""" + from gridfm_datakit.utils.idx_bus import PD + from gridfm_datakit.utils.idx_gen import PG, VG + + net = load_net_from_file(str(_GRID)) + converted = l2g.update_lightsim2grid(net) + p = net.copy_for_perturbation() + p.gens[1:, VG] += 2e-8 + p.gens[:, PG] += 3e-8 + p.buses[:, PD] += 5e-8 * (p.buses[:, PD] != 0) + updated = l2g.update_lightsim2grid(p, converted) + assert updated is converted # went in place + fresh = l2g.to_lightsim2grid(p) + for got, expected in zip(updated.ls_net.get_generators(), fresh.ls_net.get_generators()): + assert got.target_vm_pu == expected.target_vm_pu + assert got.target_p_mw == expected.target_p_mw + a = _solution_arrays(l2g.run_ls_pf(updated.ls_net, p, updated.mapping_l2g, tol=1e-12), p) + b = _solution_arrays(l2g.run_ls_pf(fresh.ls_net, p, fresh.mapping_l2g, tol=1e-12), p) + for u, v in zip(a, b): + np.testing.assert_allclose(u, v, atol=1e-11) From e722ede62c4435505848ce1d6487ceecbeba7973 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 21 Sep 2026 21:05:08 +0200 Subject: [PATCH 2/7] Fix test collection, formatting and docstrings of the lightsim2grid solver - tests/lightsim2grid/test_generate.py had the same module name as tests/test_generate.py (tests/lightsim2grid is not a package: it cannot be one, it would shadow the lightsim2grid package), which made `pytest tests/` fail at collection. Renamed to test_lightsim2grid_solver.py. - Apply the pre-commit hooks (ruff-format, add-trailing-comma). - Google-style Args / Returns / Raises sections for the functions of gridfm_datakit.lightsim2grid, and a docstring for _update_in_place. Drop the unused `mapping` argument of _snapshot. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- gridfm_datakit/lightsim2grid/__init__.py | 9 ++- gridfm_datakit/lightsim2grid/convert.py | 78 +++++++++++++++++-- gridfm_datakit/lightsim2grid/mapping.py | 10 ++- gridfm_datakit/lightsim2grid/preprocess.py | 21 ++++- ...nerate.py => test_lightsim2grid_solver.py} | 15 +++- 5 files changed, 120 insertions(+), 13 deletions(-) rename tests/lightsim2grid/{test_generate.py => test_lightsim2grid_solver.py} (95%) diff --git a/gridfm_datakit/lightsim2grid/__init__.py b/gridfm_datakit/lightsim2grid/__init__.py index 14d52cc24..188682e6b 100644 --- a/gridfm_datakit/lightsim2grid/__init__.py +++ b/gridfm_datakit/lightsim2grid/__init__.py @@ -42,7 +42,14 @@ class LoadedNetwork: def convert_net(network: Network) -> LoadedNetwork: - """Convert a gridfm_datakit Network to lightsim2grid.""" + """Convert a gridfm_datakit Network to lightsim2grid. + + Args: + network: The network to convert. + + Returns: + The lightsim2grid LSGrid, the network itself and the index maps between the two. + """ conv = to_lightsim2grid(network) return LoadedNetwork( ls_net=conv.ls_net, diff --git a/gridfm_datakit/lightsim2grid/convert.py b/gridfm_datakit/lightsim2grid/convert.py index b842afc80..1c0d8648e 100644 --- a/gridfm_datakit/lightsim2grid/convert.py +++ b/gridfm_datakit/lightsim2grid/convert.py @@ -41,6 +41,15 @@ def to_lightsim2grid(net: Network) -> ConvertedNetwork: from ``net.buses`` / ``net.gens`` / ``net.branches``, so the result is valid for one perturbed copy of the network only. Use :func:`update_lightsim2grid` to re-synchronise it with another one. + + Args: + net: The network to convert. + + Returns: + The LSGrid, its index maps to ``net`` and a snapshot of what it was built from. + + Raises: + ImportError: If lightsim2grid is not available. """ check_lightsim2grid_available() mpc = { @@ -62,16 +71,25 @@ def to_lightsim2grid(net: Network) -> ConvertedNetwork: return ConvertedNetwork( ls_net=ls_net, mapping_l2g=mapping, - state=_snapshot(net, mapping), + state=_snapshot(net), ) def _structure(net: Network) -> tuple: - """What can only be changed by rebuilding the LSGrid.""" + """What can only be changed by rebuilding the LSGrid. + + Args: + net: The network to read. + + Returns: + Copies of the bus types and shunts, of the branch ends, taps and shifts, of the + generator buses, of which buses have a load, and the in-service slack generators. + """ bus_type = np.zeros(net.buses.shape[0]) bus_type[net.buses[:, BUS_I].astype(int)] = net.buses[:, BUS_TYPE] slack = np.flatnonzero( - (net.gens[:, GEN_STATUS] > 0) & (bus_type[net.gens[:, GEN_BUS].astype(int)] == REF), + (net.gens[:, GEN_STATUS] > 0) + & (bus_type[net.gens[:, GEN_BUS].astype(int)] == REF), ) return ( net.buses[:, [BUS_I, BUS_TYPE, GS, BS]].copy(), @@ -82,11 +100,17 @@ def _structure(net: Network) -> tuple: ) -def _snapshot(net: Network, mapping: MappingL2G) -> Dict[str, Any]: +def _snapshot(net: Network) -> Dict[str, Any]: """Copy of everything of ``net`` that the LSGrid was built from. ``structure`` holds what can only be changed by rebuilding the LSGrid; the other entries are what :func:`update_lightsim2grid` can push in place. + + Args: + net: The network the LSGrid was built from. + + Returns: + The snapshot, as a dict of arrays. """ structure = _structure(net) has_load = structure[3] @@ -100,7 +124,15 @@ def _snapshot(net: Network, mapping: MappingL2G) -> Dict[str, Any]: def _same_structure(net: Network, state: Dict[str, Any]) -> bool: - """Whether the LSGrid of ``state`` can be updated in place to match ``net``.""" + """Whether the LSGrid of ``state`` can be updated in place to match ``net``. + + Args: + net: The network the LSGrid should match. + state: The snapshot of the LSGrid (see :func:`_snapshot`). + + Returns: + True if nothing that requires rebuilding the LSGrid changed. + """ return all( np.array_equal(a, b) for a, b in zip(_structure(net), state["structure"]) ) @@ -119,6 +151,14 @@ def update_lightsim2grid( It is rebuilt only if something it cannot update changed (topology, taps and shifts, shunts, bus types, which buses have a load, the slack generators). ``converted`` is modified and returned. + + Args: + net: The network the LSGrid has to match. + converted: The result of a previous call, or None to build the LSGrid. + + Returns: + ``converted`` updated in place, or a new :class:`ConvertedNetwork` if the LSGrid + had to be built or rebuilt. """ if converted is None or not _same_structure(net, converted.state): return to_lightsim2grid(net) @@ -143,6 +183,12 @@ def _set_exact(setter: Any, el_id: int, old: float, new: float) -> None: lightsim2grid setters, which would leave the LSGrid up to that far from ``net``, and make the data depend on which perturbations came before. Such a change is applied through a detour, in two steps that are both large enough. + + Args: + setter: A lightsim2grid ``change_*`` method, taking an element id and a value. + el_id: Id of the element, in the lightsim2grid ordering. + old: Value currently stored in the LSGrid. + new: Value to store. """ if abs(new - old) <= _LS_TOL_EQUAL_FLOAT: setter(el_id, float(new) + 10 * _LS_TOL_EQUAL_FLOAT) @@ -155,6 +201,19 @@ def _update_in_place( mapping: MappingL2G, old: Dict[str, Any], ) -> None: + """Push into the LSGrid what changed in ``net`` since it was last synchronised. + + Only the parameters that :func:`_same_structure` allows to change are handled: + branch impedances, admittances and statuses, generator statuses and set points, + and loads. ``old`` is updated with the new values at the end, and is left + untouched if an exception is raised before that (the caller then rebuilds). + + Args: + ls_net: The lightsim2grid LSGrid to update. + net: The network the LSGrid has to match. + mapping: Index maps between ``net`` and ``ls_net``. + old: Snapshot of what was last pushed to ``ls_net``, updated in place. + """ branch = net.branches[:, [BR_R, BR_X, BR_B, BR_STATUS]] # series impedance and charging admittance (the LSGrid has no per-element setter) @@ -199,7 +258,14 @@ def _update_in_place( def initial_voltage(net: Network) -> np.ndarray: - """Complex initial voltage (one entry per lightsim2grid bus) from ``net.buses``.""" + """Complex initial voltage (one entry per lightsim2grid bus) from ``net.buses``. + + Args: + net: The network, whose bus voltage magnitudes (pu) and angles (degrees) are used. + + Returns: + The complex voltages in pu, in the order of ``net.buses``. + """ return (net.buses[:, VM] * np.exp(1j * np.deg2rad(net.buses[:, VA]))).astype( complex, ) diff --git a/gridfm_datakit/lightsim2grid/mapping.py b/gridfm_datakit/lightsim2grid/mapping.py index 448036047..66dc79d4b 100644 --- a/gridfm_datakit/lightsim2grid/mapping.py +++ b/gridfm_datakit/lightsim2grid/mapping.py @@ -40,7 +40,15 @@ class MappingL2G: def build_l2g_maps(net: Network) -> MappingL2G: - """Build the index maps of the LSGrid that :func:`to_lightsim2grid` creates from ``net``.""" + """Build the index maps of the LSGrid that :func:`to_lightsim2grid` creates from ``net``. + + Args: + net: The network the LSGrid is built from. + + Returns: + The maps: which ``net.branches`` rows are powerlines and transformers in + lightsim2grid, and the bus index of every lightsim2grid bus. + """ is_trafo = (net.branches[:, TAP] != 0) | (net.branches[:, SHIFT] != 0) return MappingL2G( line_rows=np.flatnonzero(~is_trafo), diff --git a/gridfm_datakit/lightsim2grid/preprocess.py b/gridfm_datakit/lightsim2grid/preprocess.py index 877fca6da..28ad2212e 100644 --- a/gridfm_datakit/lightsim2grid/preprocess.py +++ b/gridfm_datakit/lightsim2grid/preprocess.py @@ -28,8 +28,23 @@ def run_ls_pf( ) -> Dict[str, Any]: """Run an AC (or DC) power flow with lightsim2grid and format the results. + The power flow starts from the voltages stored in ``net.buses`` (see + :func:`gridfm_datakit.lightsim2grid.convert.initial_voltage`). + + Args: + ls_net: The lightsim2grid LSGrid, in sync with ``net``. + net: The network the LSGrid was built from or updated with. + mapping_l2g: Index maps between ``net`` and ``ls_net``. + dc: Run a DC power flow instead of an AC one. + max_iter: Maximum number of iterations. + tol: Convergence tolerance. + + Returns: + The power flow results in PowerModels' format (see :func:`get_pf_res`), with the + solving time in ``"solve_time"``. + Raises: - ValueError: if the power flow did not converge. + ValueError: If the power flow did not converge. """ v_init = initial_voltage(net) start_time = time.perf_counter() @@ -58,7 +73,9 @@ def get_pf_res( Power flow results in a nested Dict format, similar to PowerModel's power flow results """ if v.shape[0] == 0: - raise ValueError("Power flow computation failed: lightsim2grid did not converge") + raise ValueError( + "Power flow computation failed: lightsim2grid did not converge", + ) base_mva = float(net.baseMVA) n_branches = net.branches.shape[0] diff --git a/tests/lightsim2grid/test_generate.py b/tests/lightsim2grid/test_lightsim2grid_solver.py similarity index 95% rename from tests/lightsim2grid/test_generate.py rename to tests/lightsim2grid/test_lightsim2grid_solver.py index e27436358..699c8e35a 100644 --- a/tests/lightsim2grid/test_generate.py +++ b/tests/lightsim2grid/test_lightsim2grid_solver.py @@ -185,10 +185,19 @@ def test_in_place_update_applies_changes_below_lightsim2grid_tolerance(): updated = l2g.update_lightsim2grid(p, converted) assert updated is converted # went in place fresh = l2g.to_lightsim2grid(p) - for got, expected in zip(updated.ls_net.get_generators(), fresh.ls_net.get_generators()): + for got, expected in zip( + updated.ls_net.get_generators(), + fresh.ls_net.get_generators(), + ): assert got.target_vm_pu == expected.target_vm_pu assert got.target_p_mw == expected.target_p_mw - a = _solution_arrays(l2g.run_ls_pf(updated.ls_net, p, updated.mapping_l2g, tol=1e-12), p) - b = _solution_arrays(l2g.run_ls_pf(fresh.ls_net, p, fresh.mapping_l2g, tol=1e-12), p) + a = _solution_arrays( + l2g.run_ls_pf(updated.ls_net, p, updated.mapping_l2g, tol=1e-12), + p, + ) + b = _solution_arrays( + l2g.run_ls_pf(fresh.ls_net, p, fresh.mapping_l2g, tol=1e-12), + p, + ) for u, v in zip(a, b): np.testing.assert_allclose(u, v, atol=1e-11) From a67f2061609c7c330dbe8d49654560e5dfb719f2 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 21 Sep 2026 21:05:16 +0200 Subject: [PATCH 3/7] Document settings.pf_solver, including lightsim2grid - New manual page "Power flow solver": the three engines, how the lightsim2grid model is built and kept up to date, and how its results differ from PowerModels' (AC agrees, the DC model is not the same). - New components page for gridfm_datakit.lightsim2grid, listed in mkdocs.yml. - getting_started.md: pf_solver in the settings and in the PF mode notes. - installation.md: the `lightsim2grid` extra. - pf_solver (default "powermodel", options powermodel / powsybl / lightsim2grid) in scripts/config/default.yaml and tests/config/*.yaml. `mkdocs build` and `pre-commit run --all-files` pass. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- docs/components/lightsim2grid.md | 41 ++++++++++++ docs/installation.md | 10 +++ docs/manual/getting_started.md | 2 + docs/manual/power_flow_solver.md | 64 +++++++++++++++++++ mkdocs.yml | 2 + scripts/config/default.yaml | 1 + tests/config/consistency_test.yaml | 1 + tests/config/default_opf_mode_test.yaml | 1 + tests/config/default_pf_mode_test.yaml | 1 + .../default_without_perturbation_test.yaml | 1 + 10 files changed, 124 insertions(+) create mode 100644 docs/components/lightsim2grid.md create mode 100644 docs/manual/power_flow_solver.md diff --git a/docs/components/lightsim2grid.md b/docs/components/lightsim2grid.md new file mode 100644 index 000000000..350a700e7 --- /dev/null +++ b/docs/components/lightsim2grid.md @@ -0,0 +1,41 @@ +# LightSim2grid + +This module provides the interface between a +[`Network`](network.md) and [lightsim2grid](https://github.com/Grid2op/lightsim2grid), used when +`settings.pf_solver` is `lightsim2grid`. See [Power flow solver](../manual/power_flow_solver.md). + +### `convert_net` + +::: gridfm_datakit.lightsim2grid.convert_net + +### `to_lightsim2grid` + +::: gridfm_datakit.lightsim2grid.convert.to_lightsim2grid + +### `update_lightsim2grid` + +::: gridfm_datakit.lightsim2grid.convert.update_lightsim2grid + +### `run_ls_pf` + +::: gridfm_datakit.lightsim2grid.preprocess.run_ls_pf + +### `get_pf_res` + +::: gridfm_datakit.lightsim2grid.preprocess.get_pf_res + +### `build_l2g_maps` + +::: gridfm_datakit.lightsim2grid.mapping.build_l2g_maps + +### `MappingL2G` + +::: gridfm_datakit.lightsim2grid.mapping.MappingL2G + +### `ConvertedNetwork` + +::: gridfm_datakit.lightsim2grid.convert.ConvertedNetwork + +### `LoadedNetwork` + +::: gridfm_datakit.lightsim2grid.LoadedNetwork diff --git a/docs/installation.md b/docs/installation.md index 9562e0c7f..e43fabbb5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,6 +16,16 @@ pip install gridfm-datakit gridfm_datakit setup_pm ``` +### Optional: lightsim2grid power flow solver + +The `lightsim2grid` extra installs [lightsim2grid](https://github.com/Grid2op/lightsim2grid), an +alternative to PowerModels for solving the power flow (`settings.pf_solver: lightsim2grid`, see +[Power flow solver](manual/power_flow_solver.md)): + +```bash +pip install 'gridfm-datakit[lightsim2grid]' +``` + ### Optional: dynamic (time-domain) simulation Dynamic simulation needs two extra things on top of the base install. See the diff --git a/docs/manual/getting_started.md b/docs/manual/getting_started.md index 4b0a5c5d4..dcf36c5f2 100644 --- a/docs/manual/getting_started.md +++ b/docs/manual/getting_started.md @@ -77,6 +77,7 @@ settings: include_dc_res: true # If true, also stores the results of dc power flow and dc optimal power flow pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks e.g. case10000_goc do not work with pf_fast: true dcpf_fast: true # Whether to use fast DC PF solver (compute_dc_pf from powermodels.jl); if false, uses optimizer-based DC PF + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid (the OPF is always solved by PowerModels) enable_solver_logs: false # If true, write OPF/PF solver logs to {data_dir}/solver_log; PF fast ignores logging ``` @@ -97,6 +98,7 @@ The `mode` parameter controls how the power flow scenarios are generated and val - **Use Case**: Training data for power flow, contingency analysis, etc - **Performance**: Faster as it avoids re-solving OPF for each perturbed scenario - **PF Solver Choice**: Controlled by `settings.pf_fast`. If `true`, uses the fast `compute_ac_pf` path. If `false`, uses the Ipopt-based AC PF which is slower for smaller grids but has better convergence properties for large grids. +- **PF Engine**: `settings.pf_solver` selects the engine: `powermodel` (default), `powsybl` or `lightsim2grid`. See [Power flow solver](power_flow_solver.md). ## Data Validation diff --git a/docs/manual/power_flow_solver.md b/docs/manual/power_flow_solver.md new file mode 100644 index 000000000..9998b269e --- /dev/null +++ b/docs/manual/power_flow_solver.md @@ -0,0 +1,64 @@ +# Power flow solver + +In `mode: "pf"` the power flow of every perturbed network is solved by the engine chosen with +`settings.pf_solver`. The OPF that gives the generator set points is **always** solved by +PowerModels.jl, whatever the value of this parameter. + +```yaml +settings: + pf_solver: "lightsim2grid" # options: powermodel (default), powsybl, lightsim2grid +``` + +| `pf_solver` | Engine | Extra to install | +|---|---|---| +| `powermodel` (default) | PowerModels.jl, through Julia. `pf_fast` and `dcpf_fast` choose between the direct and the optimizer-based solvers | none | +| `powsybl` | pypowsybl (Open Load Flow). Needs the network to be loaded with `network.reader: powsybl` | `pip install 'gridfm-datakit[powsybl]'` | +| `lightsim2grid` | [lightsim2grid](https://github.com/Grid2op/lightsim2grid) (C++, no Julia for the power flow) | `pip install 'gridfm-datakit[lightsim2grid]'` | + +## lightsim2grid + +The network is read with the native reader, and only the power flow solver changes: + +```yaml +network: + source: "pglib" + name: "case118_ieee" + +settings: + mode: "pf" + pf_solver: "lightsim2grid" + include_dc_res: true # the DC power flow is solved by lightsim2grid too +``` + +### How the network is converted + +The lightsim2grid model is built straight from the bus, generator and branch arrays of the +[`Network`](../components/network.md), so it is the network that the perturbations have +modified (loads, admittances, generator set points, outages). Buses and generators keep their order, +and each branch becomes a powerline or a transformer (a branch with a non zero tap or phase +shift is a transformer). + +The model is not rebuilt for every perturbation: what changed since the previous power flow +(branch impedances and statuses, generator statuses and set points, loads) is pushed into the same +model, which lets lightsim2grid keep its solver state. It is rebuilt only if something that cannot +be pushed changed (the network topology, a tap ratio or a phase shift, the shunts, the bus types, +which buses have a load, or the slack generators). The model is kept by each worker process, so it +is also reused from one scenario to the next. + +!!! note "Versions" + `pf_solver: lightsim2grid` needs a lightsim2grid providing + `lightsim2grid.network.init_from_matpower`. Pushing changes into the model needs the + `update_powerlines_parameters` and `update_trafos_parameters` methods; without them the + model is rebuilt for every power flow, which gives the same results more slowly. + +### Differences with PowerModels + +- **AC**: the results agree with those of the fast PowerModels power flow (`pf_fast: true`). On + case14 the difference is below `1e-6` on all the bus, generator and branch quantities. +- **DC**: the two solvers do not use the same DC model. lightsim2grid uses the MATPOWER one + (susceptance `1 / (x * tap)`) whereas PowerModels uses `x / (r^2 + x^2)`, so the DC angles and + flows differ, more on networks with large resistances. +- **Slack**: the active power imbalance is shared equally by the in-service generators + connected to the reference bus. +- **No optimizer**: there is no equivalent of `pf_fast: false`, and `pf_fast` / `dcpf_fast` + are ignored. diff --git a/mkdocs.yml b/mkdocs.yml index 9a58506a7..d8ce8bfca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ nav: - Topology Perturbations: manual/topology_perturbations.md - Generation Perturbations: manual/generation_perturbations.md - Admittance Perturbations: manual/admittance_perturbations.md + - Power Flow Solver: manual/power_flow_solver.md - Dynamic Simulation: manual/dynamic_simulation.md - Outputs: manual/outputs.md - Components: @@ -24,6 +25,7 @@ nav: - Process: - Process Network: components/process_network.md - Solvers: components/solvers.md + - LightSim2grid: components/lightsim2grid.md - Save: components/save.md - Utils: - Utils: components/utils.md diff --git a/scripts/config/default.yaml b/scripts/config/default.yaml index 1a6d16371..535d02803 100644 --- a/scripts/config/default.yaml +++ b/scripts/config/default.yaml @@ -49,6 +49,7 @@ settings: enable_solver_logs: true # If true, write OPF/PF/DCPF logs (incl. fast paths) to {data_dir}/solver_log. pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF. dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl) + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid. The OPF is always solved by PowerModels. powsybl requires network.reader: powsybl. opf_formulation: "polar" # Use "rectangular" for the faster ACR formulation. AC OPF is nonconvex, so validate that its potentially different local solution is acceptable before switching. max_iter: 200 # Max iterations for Ipopt-based solvers seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME. diff --git a/tests/config/consistency_test.yaml b/tests/config/consistency_test.yaml index 95d9f3be8..ebd6a6853 100644 --- a/tests/config/consistency_test.yaml +++ b/tests/config/consistency_test.yaml @@ -44,5 +44,6 @@ settings: enable_solver_logs: true # If true, write OPF/PF logs to {data_dir}/solver_log; PF fast and DCPF fast do not log. pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF. dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl) + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid. The OPF is always solved by PowerModels. powsybl requires network.reader: powsybl. max_iter: 200 # Max iterations for Ipopt-based solvers seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME. diff --git a/tests/config/default_opf_mode_test.yaml b/tests/config/default_opf_mode_test.yaml index 75fe384bf..cabeaa33c 100644 --- a/tests/config/default_opf_mode_test.yaml +++ b/tests/config/default_opf_mode_test.yaml @@ -44,5 +44,6 @@ settings: enable_solver_logs: false # If true, write OPF/PF logs to {data_dir}/solver_log; PF fast and DCPF fast do not log. pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF. dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl) + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid. The OPF is always solved by PowerModels. powsybl requires network.reader: powsybl. max_iter: 200 # Max iterations for Ipopt-based solvers seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME. diff --git a/tests/config/default_pf_mode_test.yaml b/tests/config/default_pf_mode_test.yaml index dfc057784..fab92e33e 100644 --- a/tests/config/default_pf_mode_test.yaml +++ b/tests/config/default_pf_mode_test.yaml @@ -44,5 +44,6 @@ settings: enable_solver_logs: false # If true, write OPF/PF logs to {data_dir}/solver_log; PF fast and DCPF fast do not log. pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF. dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl) + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid. The OPF is always solved by PowerModels. powsybl requires network.reader: powsybl. max_iter: 200 # Max iterations for Ipopt-based solvers seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME. diff --git a/tests/config/default_without_perturbation_test.yaml b/tests/config/default_without_perturbation_test.yaml index 989f1fcbe..d5e365d94 100644 --- a/tests/config/default_without_perturbation_test.yaml +++ b/tests/config/default_without_perturbation_test.yaml @@ -44,5 +44,6 @@ settings: enable_solver_logs: false # If true, write OPF/PF logs to {data_dir}/solver_log; PF fast and DCPF fast do not log. pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF. dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl) + pf_solver: "powermodel" # Engine solving the power flow in pf mode; options: powermodel, powsybl, lightsim2grid. The OPF is always solved by PowerModels. powsybl requires network.reader: powsybl. max_iter: 200 # Max iterations for Ipopt-based solvers seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME. From 00cf98d83538eac8f6299da9fe8082636e10d56d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 21 Sep 2026 21:11:58 +0200 Subject: [PATCH 4/7] Document how to install the lightsim2grid version with in-place updates The update_powerlines_parameters / update_trafos_parameters methods that gridfm_datakit.lightsim2grid uses to update the model in place are on the dev_gfm_datakit branch of lightsim2grid, not in a release yet. Until one has them: - installation.md: install lightsim2grid from that branch, then the extra, and what happens without the methods (same results, model rebuilt every time); - power_flow_solver.md: the version note points to it; - pyproject.toml: a TODO on the lightsim2grid extra to move its lower bound to the first release that has them. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- docs/installation.md | 17 +++++++++++++++++ docs/manual/power_flow_solver.md | 4 +++- pyproject.toml | 3 +++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/installation.md b/docs/installation.md index e43fabbb5..696f76fdf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -26,6 +26,23 @@ alternative to PowerModels for solving the power flow (`settings.pf_solver: ligh pip install 'gridfm-datakit[lightsim2grid]' ``` +!!! warning "In the meantime: install lightsim2grid from source" + The methods that let gridfm-datakit update the impedances of the lightsim2grid model in + place (`update_powerlines_parameters` and `update_trafos_parameters`) are not in a + lightsim2grid release yet. They are on the + [`dev_gfm_datakit`](https://github.com/grid2op/lightsim2grid/tree/dev_gfm_datakit) branch. + Until a release has them, install lightsim2grid from that branch, then gridfm-datakit: + + ```bash + pip install 'lightsim2grid @ git+https://github.com/grid2op/lightsim2grid.git@dev_gfm_datakit' + pip install 'gridfm-datakit[lightsim2grid]' + ``` + + This compiles C++ code, so it needs a C++ compiler and takes a few minutes. Without these + methods `pf_solver: lightsim2grid` still gives the same results, but the model is rebuilt for + every power flow, which is slower. Once a release contains them, the extra alone will be + enough and this note will be removed. + ### Optional: dynamic (time-domain) simulation Dynamic simulation needs two extra things on top of the base install. See the diff --git a/docs/manual/power_flow_solver.md b/docs/manual/power_flow_solver.md index 9998b269e..082cd0e3b 100644 --- a/docs/manual/power_flow_solver.md +++ b/docs/manual/power_flow_solver.md @@ -49,7 +49,9 @@ is also reused from one scenario to the next. `pf_solver: lightsim2grid` needs a lightsim2grid providing `lightsim2grid.network.init_from_matpower`. Pushing changes into the model needs the `update_powerlines_parameters` and `update_trafos_parameters` methods; without them the - model is rebuilt for every power flow, which gives the same results more slowly. + model is rebuilt for every power flow, which gives the same results more slowly. Until a + lightsim2grid release has them, they are available by + [installing lightsim2grid from source](../installation.md#optional-lightsim2grid-power-flow-solver). ### Differences with PowerModels diff --git a/pyproject.toml b/pyproject.toml index 5922bab6f..6f2edaf68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,9 @@ powsybl = [ "pypowsybl" ] +# TODO: move the lower bound to the first lightsim2grid release with update_powerlines_parameters / +# update_trafos_parameters (branch dev_gfm_datakit). Older versions work, but rebuild the model for +# every power flow. See docs/installation.md. lightsim2grid = [ "lightsim2grid>=1.1.0" ] From 39ee1da08e774af19ea0bc78bcf088dd9ad33e02 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 22 Sep 2026 16:27:22 +0200 Subject: [PATCH 5/7] Narrow the in-place update fallback to AttributeError and run lightsim2grid tests in CI update_lightsim2grid caught a bare Exception before falling back to rebuilding the LSGrid; narrow it to AttributeError, the only failure expected on a released lightsim2grid that lacks update_powerlines_parameters / update_trafos_parameters, so a genuine bug in _update_in_place surfaces instead of being silently swallowed. dynamic-pytests already installs the heavy solver extras and runs the powsybl/dynawo tests; fold lightsim2grid into that job the same way so tests/lightsim2grid stops being skipped in CI. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- .github/workflows/ci-build.yaml | 6 +++--- gridfm_datakit/lightsim2grid/convert.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index b44f52b96..6cfe6f42f 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -155,7 +155,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip wheel - pip install -e ".[test,dynamic,powsybl]" + pip install -e ".[test,dynamic,powsybl,lightsim2grid]" - name: Set up Julia packages (PowerModels) run: python -c "from juliacall import Main as jl; jl.seval('using PowerModels, Ipopt, Memento')" @@ -165,8 +165,8 @@ jobs: "$HOME/dynawo/dynawo.sh" version python -c "from gridfm_datakit.dynamic.dynawo.api import check_dynawo_available; check_dynawo_available()" - - name: Dynawo and powsybl tests - run: pytest tests/dynamic tests/powsybl -v + - name: Dynawo, powsybl and lightsim2grid tests + run: pytest tests/dynamic tests/powsybl tests/lightsim2grid -v codeql: name: CodeQL (Python) diff --git a/gridfm_datakit/lightsim2grid/convert.py b/gridfm_datakit/lightsim2grid/convert.py index 1c0d8648e..2296ab8b6 100644 --- a/gridfm_datakit/lightsim2grid/convert.py +++ b/gridfm_datakit/lightsim2grid/convert.py @@ -166,8 +166,10 @@ def update_lightsim2grid( ls_net, mapping, old = converted.ls_net, converted.mapping_l2g, converted.state try: _update_in_place(ls_net, net, mapping, old) - except Exception: - # half-updated: nothing about the LSGrid can be trusted anymore + except AttributeError: + # a released lightsim2grid without update_powerlines_parameters / + # update_trafos_parameters: half-updated, nothing about the LSGrid can + # be trusted anymore, fall back to rebuilding it from scratch return to_lightsim2grid(net) return converted From 7f29aa043c1415c1c6e8dff5f8ed190ba914c96c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 22 Sep 2026 17:17:02 +0200 Subject: [PATCH 6/7] Address the TODO on tracking the lightsim2grid in-place update path romeokienzler asked for a marker so the in-place-update coverage of tests/lightsim2grid isn't quietly lost: CI installs a released lightsim2grid, which has no update_powerlines_parameters / update_trafos_parameters yet, so test_in_place_update_matches_a_rebuild was actually asserting the wrong thing there (n_rebuilt == 1, when every branch-parameter change in fact falls back to a rebuild -- verified locally by removing those methods from the LSGrid class). Only assert the exact rebuild count when the LSGrid supports the in-place path, and cross-reference the matching TODO between pyproject.toml and the test so bumping the lightsim2grid lower bound to the release that has these methods flips CI onto asserting n_rebuilt == 1 again automatically. Assisted-by: Claude Code (claude-sonnet-5) Signed-off-by: DONNOT Benjamin --- pyproject.toml | 7 ++++--- tests/lightsim2grid/test_lightsim2grid_solver.py | 11 ++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f2edaf68..c03ac6b78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,9 +83,10 @@ powsybl = [ "pypowsybl" ] -# TODO: move the lower bound to the first lightsim2grid release with update_powerlines_parameters / -# update_trafos_parameters (branch dev_gfm_datakit). Older versions work, but rebuild the model for -# every power flow. See docs/installation.md. +# TODO(lightsim2grid>=X): move the lower bound to the first lightsim2grid release with +# update_powerlines_parameters / update_trafos_parameters (branch dev_gfm_datakit). Older versions +# work, but rebuild the model for every power flow, which is also what CI exercises until then +# (see the matching TODO in tests/lightsim2grid/test_lightsim2grid_solver.py). See docs/installation.md. lightsim2grid = [ "lightsim2grid>=1.1.0" ] diff --git a/tests/lightsim2grid/test_lightsim2grid_solver.py b/tests/lightsim2grid/test_lightsim2grid_solver.py index 699c8e35a..c3b3a26ec 100644 --- a/tests/lightsim2grid/test_lightsim2grid_solver.py +++ b/tests/lightsim2grid/test_lightsim2grid_solver.py @@ -121,6 +121,14 @@ def _perturbed(net, rng, step): def test_in_place_update_matches_a_rebuild(): """Updating the LSGrid in place, across perturbations, must give exactly what a fresh LSGrid gives.""" net = load_net_from_file(str(_GRID)) + # TODO(lightsim2grid>=X): a released lightsim2grid has no update_powerlines_parameters / + # update_trafos_parameters yet (see the TODO on the lightsim2grid extra in pyproject.toml), so + # here every branch-parameter change falls back to a rebuild instead of going in place. Once a + # release has them, this starts asserting n_rebuilt == 1 automatically. + supports_in_place = hasattr( + l2g.to_lightsim2grid(net).ls_net, + "update_powerlines_parameters", + ) rng = np.random.default_rng(0) converted = None n_compared = n_rebuilt = 0 @@ -149,7 +157,8 @@ def test_in_place_update_matches_a_rebuild(): for updated, rebuilt in zip(*results): np.testing.assert_allclose(updated, rebuilt, atol=1e-10, equal_nan=True) assert n_compared > 40 - assert n_rebuilt == 1 # only the initial build: everything else went in place + if supports_in_place: + assert n_rebuilt == 1 # only the initial build: everything else went in place def test_structural_change_triggers_a_rebuild(): From 45f877580cdbee20d774625c72083c15063cb006 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 23 Sep 2026 01:02:07 +0000 Subject: [PATCH 7/7] Only fall back to a rebuild when branch parameters change on released lightsim2grid _update_in_place looked up update_powerlines_parameters / update_trafos_parameters unconditionally, so on lightsim2grid 1.1.0 (which has neither) every update raised AttributeError and rebuilt the LSGrid, even for load or generator changes only. This failed test_in_place_update_applies_changes_below_lightsim2grid_tolerance in CI. Look the method up only when branch parameters actually changed. Assisted-by: Claude Code Signed-off-by: DONNOT Benjamin --- gridfm_datakit/lightsim2grid/convert.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gridfm_datakit/lightsim2grid/convert.py b/gridfm_datakit/lightsim2grid/convert.py index 2296ab8b6..1aadae7d5 100644 --- a/gridfm_datakit/lightsim2grid/convert.py +++ b/gridfm_datakit/lightsim2grid/convert.py @@ -218,12 +218,15 @@ def _update_in_place( """ branch = net.branches[:, [BR_R, BR_X, BR_B, BR_STATUS]] - # series impedance and charging admittance (the LSGrid has no per-element setter) - for rows, update, split_b in ( - (mapping.line_rows, ls_net.update_powerlines_parameters, True), - (mapping.trafo_rows, ls_net.update_trafos_parameters, False), + # series impedance and charging admittance (the LSGrid has no per-element setter). + # The update method is looked up only when needed: a released lightsim2grid does not + # have it, and only a change of these parameters has to fall back to a rebuild. + for rows, update_name, split_b in ( + (mapping.line_rows, "update_powerlines_parameters", True), + (mapping.trafo_rows, "update_trafos_parameters", False), ): if rows.size and not np.array_equal(branch[rows, :3], old["branch"][rows, :3]): + update = getattr(ls_net, update_name) r, x, b = branch[rows, 0], branch[rows, 1], 1j * branch[rows, 2] if split_b: # a line: the charging is split in two halves update(r, x, b / 2, b / 2)